16 Commits
Author SHA1 Message Date
sihoo 2dd943d2fa 백품타 랭킹 화면 필터 칩/내 순위 강조색을 테마 강조색으로 변경
학년/기간 필터 칩(ChoiceChip)의 selectedColor와 "내 순위" 행 강조 배경이
AppPalette.ink로 고정돼 있어서 테마를 적용해도 검정으로 남아있었다.
globalProfileTheme의 accent를 쓰도록 변경.
2026-10-01 23:40:15 +00:00
sihoo 1d9b86d3e8 출석 달력 검정색 테마 반영 + 웹에서 NFC 타일 잠금 처리
출석 달력: 공부한 날 표시/오늘 테두리/범례가 AppPalette.ink로 고정돼 있어서
테마를 적용해도 검정으로 남아있던 것을 테마 강조색으로 바꿨다.

메인 대시보드: 웹은 NFC 하드웨어에 접근할 수 없으므로, "등록된 기기가
아닐 때"와 똑같은 패턴(자물쇠 아이콘 + 회색 + 탭하면 안내 메시지)으로
NFC 태그/NFC 태그 쓰기 타일을 잠근다. 실제 눌러지는 동작은 기존 기기
불일치 처리와 동일하게 안내만 띄우고 화면 이동은 막는 방식이다.
2026-10-01 23:34:54 +00:00
sihoo 383d3143c8 커스텀창 체험하기 미리보기가 제목 알약/준비중 타일까지 전부 반영되게 수정
TitlePill이 전역(globalProfileTheme, 실제 장착된 테마)만 구독하고 있어서,
커스텀창 안에서 체험하기로 다른 템플릿을 미리보는 중에도 제목 알약은
실제 장착된 테마 색 그대로 남아있었다. TitlePill에 previewTheme 파라미터를
추가해 넘기면 전역 테마보다 우선하도록 하고, 커스텀창에서 자신의
_displayTheme(체험 중인 테마)를 넘기게 했다.

"랭킹·친구창 배경"/"출석·그룹창 표시" 준비중 타일도 같은 이유로 실제 장착된
테마(AppPalette.linen/sage, 전역)를 쓰고 있어서 체험하기를 눌러도 안 바뀌던
것을, _CategoryTile에 theme 파라미터를 추가해 _displayTheme을 쓰도록 고쳤다.
2026-10-01 23:30:24 +00:00
sihoo 2a5b0a8b32 선생님 호출/반출 신청 화면의 검정 아이콘·버튼을 테마 강조색으로 변경
teacher_call_screen: 방문목적 선택 칩, 선생님 선택 타일, 호출하기 버튼,
섹션 제목 강조바, 선생님 상태 아이콘(수업중 아닐 때)이 전부 AppPalette.ink로
고정돼 있어서 테마를 적용해도 검정으로 남아있었다. globalProfileTheme의
accent를 쓰도록 변경.

device_checkout_request_screen: "시작 시각"/"종료 시각" OutlinedButton이
스타일 지정이 아예 없어서 기본 색(검정 계열)으로 나오던 것도 같이 테마
강조색으로 맞췄다.
2026-10-01 23:24:11 +00:00
sihoo 7e7585fa8f 웹 브라우저에서는 NFC 세션을 아예 시작하지 않게 수정
웹은 NFC 하드웨어에 접근할 방법이 없는데도 isAvailable()/startSession()을
그대로 호출하고 있었다. kIsWeb이면 바로 "스마트폰 앱에서만 사용할 수 있어요"
안내만 보여주고 세션 시도 자체를 건너뛰게 했다 (출석체크 화면 + 관리자
태그 쓰기 화면 둘 다). dispose()의 stopSession() 호출도 웹에서는 건너뛴다.
2026-10-01 23:15:01 +00:00
sihoo 12780eb3af 반출/백판/출석 달력/친구/그룹 스터디 검정 버튼을 테마 강조색으로 변경
각 화면의 주요 CTA 버튼(ElevatedButton/FloatingActionButton)이 고정 색
AppPalette.ink로 칠해져 있어서, 다른 화면들처럼 테마를 적용해도 버튼만
계속 검정색으로 남아있었다. globalProfileTheme.value?.accent로 교체해서
장착한 템플릿의 강조색을 따라가게 했다. AppBar 아이콘 색(foregroundColor)과
danger/warning 등 의미 있는 색은 그대로 둔다.
2026-10-01 07:17:11 +00:00
sihoo f6be60ef46 템플릿 테마를 앱 전체로 확장 - 제목 알약, 백품타, 모든 화면 배경/카드색
구조색 4종(mist/paper/linen/sage)을 const에서 뺀 뒤 AppPalette.applyTemplateTheme()
으로 템플릿 장착/해제 시 한 번에 바꿔치기하게 했다. 이 4개는 전부 "배경/카드/
구분선" 용도로만 쓰이던 색이라, 장착 즉시 거의 모든 화면(백품타 포함, AppPalette.mist/
paper/sage를 쓰는 화면이면 코드 수정 없이 자동 반영)의 배경·카드·테두리가 테마를
따라간다. ink는 텍스트/버튼 등 너무 광범위하게 쓰여서 그대로 고정.

제목 알약(TitlePill, 24개 화면이 공유)은 ink를 직접 바꾸면 안 되므로 별도로
globalProfileTheme(ValueNotifier) 하나를 구독해서 배경을 theme.secondary로
바꾸게 했다 - 이 위젯 하나만 고쳐서 모든 화면의 제목 배경에 한 번에 적용.

백품타(study_timer_screen)는 시작/재개 버튼과 큰 타이머 숫자처럼 "중립" 요소만
테마 강조색(accent)으로 바꾸고, 일시정지(warning)/종료(danger) 같은 경고성
색은 의미가 있어서 그대로 뒀다.

main_dashboard/profile_customize_screen은 실제로 장착이 바뀔 때마다
applyGlobalProfileTheme()을 호출해 위 두 전역 상태를 동기화한다.

구조색을 const에서 빼면서 기존에 const로 선언돼 있던 지점 몇 곳
(app_notice, device_checkout_ledger_page, group_detail_screen,
main_dashboard, teacher_attendance_page)에서 const 컴파일 에러가 나서
해당 const 키워드만 제거해 수정했다.
2026-10-01 07:09:06 +00:00
sihoo 726d2dc632 커스텀창 - "체험하기"를 누르면 화면 전체 테마가 즉시 바뀌도록 수정
기존엔 미리보기가 큰 타일 하나만 바꾸고, 배경/카드 등 화면 전체는 실제
장착된 테마를 따로 불러와 보여줬다. 이제는 _resolvePreviewTemplate()
하나로 "지금 보여줄 테마"를 통일해서, 체험하기로 고른 템플릿이 배경색/
보유 캐시 바/카드 전부에 즉시 반영된다. "기본 모습"을 고르면 화면 전체가
원래 모습으로 돌아간다.

ShopTemplate에 code 필드를 추가해서 profileThemes 맵을 바로 찾아 쓸 수
있게 했다.
2026-10-01 06:29:40 +00:00
sihoo 4e74667993 프로필 꾸미기 테마 적용 범위 확장 - 커스텀창 → 학생/교사/관리자 대시보드 타일
1. 커스텀(상점) 화면 자체도 장착한 테마를 반영하도록 함: 배경색, 보유 캐시
   바 배경, 템플릿 카드 배경/테두리/텍스트, 체험하기·장착하기 버튼 색까지
   전부 테마를 따라간다. 미리보기는 기존처럼 큰 타일 하나만 별도로 바뀐다.
2. 메인 대시보드의 테마 반영 대상을 학생+관리자에서 학생+교사+관리자로
   확장 - 백엔드에서 교사 계정도 템플릿을 무료로 쓸 수 있게 된 것과 짝.
   같은 _tileSpecs/_buildModernCard 코드를 역할 구분 없이 공유하므로
   테마 로딩 조건만 넓히면 교사/관리자 타일에도 자동으로 적용된다.
2026-10-01 06:23:12 +00:00
sihoo 0fc80eb5c5 템플릿마다 메인 대시보드 전체 테마(배경/타일/아이콘 색)가 같이 바뀌게 추가
용어 정리: 템플릿 = 상점에서 사는 색/애니메이션 상품, 테마 = 그 템플릿이
몰고 오는 화면 전체 디자인(배경색/타일색/아이콘색). 1단계로 메인 대시보드부터
적용 - 프로필 카드 그라데이션뿐 아니라 페이지 배경, 타일 카드 배경/테두리,
아이콘 동그라미 배경, 타일 아이콘 강조색, 제목/부제목 텍스트 색까지 장착한
템플릿에 맞춰 통째로 바뀐다. 잠긴/비활성 타일(NFC 미인증 등)은 기존처럼
중립 회색(sage)을 그대로 써서 상태 구분은 유지.

6개 기존 템플릿마다 직접 디자인한 테마(lib/theme/profile_themes.dart)를
새로 만들었고, 최고가 "갤럭시" 템플릿은 다른 5개와 달리 통째로 다크 테마로
디자인해 프리미엄 아이템 느낌을 살렸다.

백엔드 /api/shop/equipped가 템플릿 code를 같이 내려주도록 수정해서
프론트엔드가 code로 디자인해둔 테마를 찾아 쓸 수 있게 했다.
2026-10-01 06:13:18 +00:00
sihoo edf0444b71 프로필 꾸미기 미리보기 테두리/체험 버튼 추가, 확인 버튼 엔터 지원, 비밀번호 홀드 보기 추가
- 프로필 꾸미기: 미리보기 중인 큰 타일의 테두리도 테마 색으로 바뀌게 수정,
  미구매 템플릿에 "체험하기" 버튼을 따로 둬서 사기 전에도 눌러볼 수 있게 함
- 앱 전반의 확인/삭제/저장 같은 다이얼로그 기본 액션 버튼에 autofocus를 줘서
  엔터 키로도 눌리게 함 (텍스트 입력이 있는 다이얼로그는 입력창 쪽에
  autofocus + onSubmitted를 달아 입력 중 바로 엔터로 제출되게 처리)
- HoldToRevealPasswordField 위젯 추가 - 눈 아이콘을 누르고 있는 동안만
  비밀번호가 평문으로 보임. 로그인/간편인증/초기 비밀번호 변경 화면에 적용
2026-10-01 05:52:57 +00:00
sihoo 6b53214913 프로필 꾸미기 상점에 구매 전 미리보기 기능 추가
템플릿 카드를 누르면(구매 여부 상관없이) 위쪽 "기본 프로필 꾸미기" 큰 타일이
그 테마 그라데이션으로 바로 바뀌어서 사기 전에 실제 느낌을 볼 수 있다.
미리보기 중인 카드에는 "미리보기" 표시와 테두리로 구분을 주고, 아무것도
직접 고르지 않았을 땐 실제 장착된 테마를 기본으로 보여준다.
2026-10-01 04:16:22 +00:00
sihoo 13a86ec95e 백품타에 스페이스바 단축키 추가 - 시작/일시정지/재개 토글
멈춰있으면 시작, 돌아가는 중이면 일시정지, 일시정지 중이면 재개로
스페이스바 한 키로 토글되게 했다. 뽀모도로가 공부/휴식을 자동으로
전환하는 중에는 화면에서도 수동 버튼을 숨기므로 스페이스바도 똑같이
막아서 자동 전환을 방해하지 않게 한다. 뽀모도로 설정 모달을 닫으면
포커스를 다시 돌려줘서 스페이스바가 계속 먹히게 처리.
2026-10-01 04:08:27 +00:00
sihoo a29efe77ad 프로필 상점 준비중 타일 텍스트 오버플로우 수정 + 관리자 테마 반영
카테고리 타일 내부 패딩/간격을 줄이고 제목·부제목에 ellipsis를 줘서
"준비중" 텍스트가 타일 밖으로 삐져나오던 문제를 해결. 관리자 계정도
백엔드에서 템플릿을 무료로 쓸 수 있게 된 것에 맞춰, 메인 대시보드
프로필 카드의 장착 테마 반영을 관리자 계정에도 적용한다.
2026-10-01 04:03:47 +00:00
sihoo a4a7b5be48 프로필 꾸미기 상점 화면 레이아웃 버그 수정 - 카테고리 타일이 안 보이던 문제
ListView(스크롤 영역) 안에서 Row에 stretch 정렬만 주면 높이가 정해지지 않아
자식 크기 계산이 깨지는 문제였다. 카테고리 타일 Row 전체를 SizedBox로
감싸 높이를 고정하니 "기본 프로필 꾸미기" 타일과 그 아래 템플릿 목록이
정상적으로 그려진다.
2026-10-01 00:38:06 +00:00
sihoo 705c0c086a 프로필 꾸미기 상점 추가 (1단계: 기본 프로필 테마) - 공부시간을 캐시로 써서 구매/장착
백엔드에 새로 생긴 /api/shop/* 엔드포인트를 붙여서 프로필 커스텀 화면을
실제 상점으로 채웠다. 랭킹/친구창 배경, 출석/그룹창 표시 카테고리는
와이어프레임 자리만 잡아둔 준비중 상태 - 1단계는 기본 프로필 테마만 동작.
장착한 테마는 메인 대시보드 프로필 카드 배경에 바로 반영된다.
학생이 직접 템플릿을 만들어 파는 기능은 이후 단계로 보류.
2026-09-30 11:17:56 +00:00
28 changed files with 1449 additions and 276 deletions
@@ -105,7 +105,8 @@ class NfcPocketCheckinController extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다. // 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
NfcManager.instance.stopSession(); // 🌐 웹에서는 애초에 세션을 시작하지 않으므로 stopSession도 호출하지 않는다.
if (!kIsWeb) NfcManager.instance.stopSession();
_violationSub?.cancel(); _violationSub?.cancel();
_checkedOutSub?.cancel(); _checkedOutSub?.cancel();
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라, // iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
@@ -119,6 +120,14 @@ class NfcPocketCheckinController extends ChangeNotifier {
/// 📡 NFC 감지 세션 시작 /// 📡 NFC 감지 세션 시작
void _startNfcSession() async { void _startNfcSession() async {
// 🌐 웹 브라우저는 NFC 하드웨어에 접근할 방법이 없어서, 시도조차 하지 않고
// 바로 안내 문구만 보여준다 (괜히 켜는 척하다 먹통되는 것 방지).
if (kIsWeb) {
_statusMessage = "NFC 태그 기능은 스마트폰 앱에서만 사용할 수 있어요.";
notifyListeners();
return;
}
bool isAvailable = await NfcManager.instance.isAvailable(); bool isAvailable = await NfcManager.instance.isAvailable();
if (!isAvailable) { if (!isAvailable) {
_statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다."; _statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
+7 -1
View File
@@ -31,7 +31,8 @@ class NfcTagWriterController extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
NfcManager.instance.stopSession(); // 🌐 웹에서는 애초에 세션을 시작하지 않으므로 stopSession도 호출하지 않는다.
if (!kIsWeb) NfcManager.instance.stopSession();
super.dispose(); super.dispose();
} }
@@ -40,6 +41,11 @@ class NfcTagWriterController extends ChangeNotifier {
onMessage("주머니 번호를 입력해주세요.", true); onMessage("주머니 번호를 입력해주세요.", true);
return; return;
} }
// 🌐 웹 브라우저는 NFC 하드웨어에 접근할 방법이 없어서 시도조차 하지 않는다.
if (kIsWeb) {
onMessage("NFC 태그 쓰기는 스마트폰 앱에서만 사용할 수 있어요.", true);
return;
}
final String pocketLabel = "POCKET_$number"; final String pocketLabel = "POCKET_$number";
bool isAvailable = await NfcManager.instance.isAvailable(); bool isAvailable = await NfcManager.instance.isAvailable();
+149
View File
@@ -0,0 +1,149 @@
// 🛍️ 프로필 꾸미기 상점 기능(서버 통신/상태) 담당 컨트롤러.
// UI는 lib/ui/profile_customize_screen.dart가 담당한다.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show Color;
import 'package:http/http.dart' as http;
import '../config.dart';
import '../theme/profile_themes.dart';
class ShopTemplate {
final int id;
final String code;
final String category;
final String name;
final int price;
final Color primaryColor;
final Color secondaryColor;
final bool owned;
final bool equipped;
ShopTemplate({
required this.id,
required this.code,
required this.category,
required this.name,
required this.price,
required this.primaryColor,
required this.secondaryColor,
required this.owned,
required this.equipped,
});
factory ShopTemplate.fromJson(Map<String, dynamic> json) {
return ShopTemplate(
id: json['id'] as int,
code: json['code'] as String,
category: json['category'] as String,
name: json['name'] as String,
price: json['price'] as int,
primaryColor: _parseColor(json['primaryColor'] as String),
secondaryColor: _parseColor(json['secondaryColor'] as String),
owned: json['owned'] == true,
equipped: json['equipped'] == true,
);
}
}
Color _parseColor(String hex) {
return Color(int.parse(hex.substring(2), radix: 16));
}
class ProfileShopController extends ChangeNotifier {
bool _isLoading = false;
bool get isLoading => _isLoading;
int cashBalance = 0;
List<ShopTemplate> templates = [];
void _safeNotify() {
if (hasListeners) notifyListeners();
}
Future<void> loadCatalog(String studentId) async {
_isLoading = true;
_safeNotify();
try {
final url = Uri.parse(
'$baseUrl/api/shop/catalog?studentId=${Uri.encodeComponent(studentId)}',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
cashBalance = (data['cashBalance'] as num?)?.toInt() ?? 0;
templates = ((data['templates'] as List?) ?? [])
.map((t) => ShopTemplate.fromJson(t as Map<String, dynamic>))
.toList();
}
} catch (_) {
// 네트워크 에러여도 화면은 빈 상태로라도 그려지게 조용히 넘어간다.
} finally {
_isLoading = false;
_safeNotify();
}
}
Future<(bool success, String message)> purchase(
String studentId,
int templateId,
) async {
try {
final url = Uri.parse('$baseUrl/api/shop/purchase');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'studentId': studentId, 'templateId': templateId}),
);
final data = jsonDecode(response.body);
final success = data['status'] == 'success';
if (success) {
await loadCatalog(studentId);
}
return (success, '${data['message'] ?? (success ? '구매 완료!' : '구매 실패')}');
} catch (e) {
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
}
}
Future<(bool success, String message)> equip(
String studentId,
int? templateId,
) async {
try {
final url = Uri.parse('$baseUrl/api/shop/equip');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'studentId': studentId, 'templateId': templateId}),
);
final data = jsonDecode(response.body);
final success = data['status'] == 'success';
if (success) {
await loadCatalog(studentId);
}
return (success, '${data['message'] ?? (success ? '장착 완료' : '장착 실패')}');
} catch (e) {
return (false, '서버와 연결할 수 없습니다. (네트워크 에러)');
}
}
/// 🏠 메인 대시보드 프로필 카드 렌더링 전용 - 상점 화면을 열지 않고도
/// 현재 장착 중인 테마 색상만 가볍게 조회한다.
static Future<ProfileTheme?> fetchEquippedTheme(String studentId) async {
try {
final url = Uri.parse(
'$baseUrl/api/shop/equipped?studentId=${Uri.encodeComponent(studentId)}',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['equipped'] == true) {
return profileThemes[data['code'] as String?];
}
}
} catch (_) {
// 실패하면 그냥 기본 모습으로 보여준다.
}
return null;
}
}
+6 -6
View File
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import '../config.dart'; import '../config.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../ui/app_notice.dart'; import '../ui/app_notice.dart';
import '../ui/hold_to_reveal_password_field.dart';
import '../ui/login_screen.dart'; import '../ui/login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요. // 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
@@ -75,13 +76,12 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
TextField( HoldToRevealPasswordField(
controller: _pwController, controller: _pwController,
obscureText: true, autofocus: true,
decoration: const InputDecoration( labelText: '새로운 비밀번호 입력',
labelText: '새로운 비밀번호 입력', textInputAction: TextInputAction.done,
border: OutlineInputBorder(), onSubmitted: (_) => _updatePassword(),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
+24 -4
View File
@@ -1,16 +1,36 @@
// 🎨 앱 전역 무채색 팔레트 (coolors.co: 1c1c1c-daddd8-ecebe4-eef0f2-fafaff). // 🎨 앱 전역 무채색 팔레트 (coolors.co: 1c1c1c-daddd8-ecebe4-eef0f2-fafaff).
// 배경/카드/배너 같은 구조적 색상은 전부 이 5색으로 통일한다. // 배경/카드/배너 같은 구조적 색상은 전부 이 5색으로 통일한다.
// 삭제/위반/거절처럼 사용자가 즉시 알아채야 하는 경고성 색상(빨강 등)만 예외로 유지한다. // 삭제/위반/거절처럼 사용자가 즉시 알아채야 하는 경고성 색상(빨강 등)만 예외로 유지한다.
//
// 🛍️ mist/paper/linen/sage(구조색 4종)는 프로필 꾸미기 템플릿을 장착하면 화면
// 전체에 그 테마가 번지도록 const를 빼고 바꿔치기 가능하게 했다. ink는 텍스트/
// 버튼 등 너무 많은 곳에서 쓰여서 바꾸면 가독성이 깨질 수 있어 그대로 고정 둔다.
// applyTemplateTheme()로만 바꾸고, 직접 대입하지 않는다.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'profile_themes.dart';
class AppPalette { class AppPalette {
AppPalette._(); AppPalette._();
static const Color ink = Color(0xFF1C1C1C); // 가장 어두운 톤 — 배너/주요 텍스트/버튼 static const Color ink = Color(0xFF1C1C1C); // 가장 어두운 톤 — 배너/주요 텍스트/버튼
static const Color sage = Color(0xFFDADDD8); // 보더/구분선/보조 표면
static const Color linen = Color(0xFFECEBE4); // 은은한 카드/배지 배경 static const Color _defaultSage = Color(0xFFDADDD8);
static const Color mist = Color(0xFFEEF0F2); // 페이지 배경 static const Color _defaultLinen = Color(0xFFECEBE4);
static const Color paper = Color(0xFFFAFAFF); // 카드 표면(거의 흰색) static const Color _defaultMist = Color(0xFFEEF0F2);
static const Color _defaultPaper = Color(0xFFFAFAFF);
static Color sage = _defaultSage; // 보더/구분선/보조 표면
static Color linen = _defaultLinen; // 은은한 카드/배지 배경
static Color mist = _defaultMist; // 페이지 배경
static Color paper = _defaultPaper; // 카드 표면(거의 흰색)
/// 🛍️ 장착한 템플릿에 맞춰 구조색 4종을 한 번에 바꾼다. null을 주면 기본값으로 복원.
static void applyTemplateTheme(ProfileTheme? theme) {
mist = theme?.background ?? _defaultMist;
paper = theme?.surface ?? _defaultPaper;
linen = theme?.surfaceAlt ?? _defaultLinen;
sage = theme?.border ?? _defaultSage;
}
// 🚨 경고성 색상 3종 - 예전엔 화면마다 Colors.red/red[400]/red[700]/redAccent처럼 // 🚨 경고성 색상 3종 - 예전엔 화면마다 Colors.red/red[400]/red[700]/redAccent처럼
// 제각각 써서 같은 의미인데 색이 미묘하게 달랐다. 이제 의미별로 딱 하나씩만 쓴다. // 제각각 써서 같은 의미인데 색이 미묘하게 달랐다. 이제 의미별로 딱 하나씩만 쓴다.
+98
View File
@@ -0,0 +1,98 @@
// 🎨 프로필 꾸미기 "템플릿"을 장착하면 프로필 카드 색뿐 아니라 화면 전체의
// 배경/타일/아이콘 색까지 통째로 바뀌는 "테마" 데이터.
// 템플릿은 상점에서 사는 색/애니메이션 상품, 테마는 그 템플릿이 몰고 오는
// 화면 전체 디자인(배경색/타일색/아이콘색/모양)을 가리킨다 - 용어 구분은
// 사장님이 정해준 것을 그대로 따른다.
//
// 템플릿 하나하나에 대해 직접 디자인한 고정 팔레트라서, 서버에서 내려주는
// code(예: "lavender")로 이 맵을 찾아 쓴다. 템플릿이 늘어나면 여기도 추가해야 한다.
import 'package:flutter/material.dart';
class ProfileTheme {
final Color primary; // 프로필 카드 그라데이션 시작색 (템플릿 색)
final Color secondary; // 프로필 카드 그라데이션 끝색 (템플릿 색)
final Color background; // 페이지 배경
final Color surface; // 타일/카드 배경
final Color surfaceAlt; // 아이콘 동그라미 등 보조 표면
final Color border; // 타일 테두리/구분선
final Color accent; // 타일 아이콘 등 강조색
final Color onBackground; // 배경/타일 위에 올라가는 기본 텍스트 색
final bool isDark;
const ProfileTheme({
required this.primary,
required this.secondary,
required this.background,
required this.surface,
required this.surfaceAlt,
required this.border,
required this.accent,
required this.onBackground,
this.isDark = false,
});
}
const Map<String, ProfileTheme> profileThemes = {
'lavender': ProfileTheme(
primary: Color(0xFFB39DDB),
secondary: Color(0xFF7E57C2),
background: Color(0xFFF5F0FA),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFEDE4F7),
border: Color(0xFFDCCCEF),
accent: Color(0xFF7E57C2),
onBackground: Color(0xFF1C1C1C),
),
'mint': ProfileTheme(
primary: Color(0xFF80CBC4),
secondary: Color(0xFF00897B),
background: Color(0xFFEAF6F4),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFDBF0EC),
border: Color(0xFFBFE3DC),
accent: Color(0xFF00897B),
onBackground: Color(0xFF1C1C1C),
),
'sunset': ProfileTheme(
primary: Color(0xFFFFB74D),
secondary: Color(0xFFE64A19),
background: Color(0xFFFFF3E6),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFFFE5CC),
border: Color(0xFFFFD1A6),
accent: Color(0xFFE64A19),
onBackground: Color(0xFF1C1C1C),
),
'ocean': ProfileTheme(
primary: Color(0xFF4FC3F7),
secondary: Color(0xFF01579B),
background: Color(0xFFEAF6FC),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFD9EEF9),
border: Color(0xFFBBDDF0),
accent: Color(0xFF01579B),
onBackground: Color(0xFF1C1C1C),
),
'aurora': ProfileTheme(
primary: Color(0xFF81C784),
secondary: Color(0xFF1565C0),
background: Color(0xFFEDF6EF),
surface: Color(0xFFFFFFFF),
surfaceAlt: Color(0xFFDCEEE0),
border: Color(0xFFC3E2CA),
accent: Color(0xFF1565C0),
onBackground: Color(0xFF1C1C1C),
),
// 🌌 최고가 템플릿 - 다른 5개와 달리 통째로 다크 테마라서 들어가자마자 확 다르게 느껴진다.
'galaxy': ProfileTheme(
primary: Color(0xFF9575CD),
secondary: Color(0xFF1A237E),
background: Color(0xFF14122B),
surface: Color(0xFF211F42),
surfaceAlt: Color(0xFF2C2A52),
border: Color(0xFF3A3768),
accent: Color(0xFFB39DDB),
onBackground: Color(0xFFF5F3FF),
isDark: true,
),
};
+15
View File
@@ -0,0 +1,15 @@
// 🌐 지금 장착된 프로필 테마를 앱 전체에 퍼뜨리는 통로.
// AppPalette의 구조색(mist/paper/linen/sage)은 전역 static 값이라 바꾸면 되지만,
// TitlePill처럼 "실시간으로" 다시 그려져야 하는 공유 위젯은 구독할 대상이
// 따로 필요해서 ValueNotifier를 하나 더 둔다. 템플릿을 장착/해제할 때는
// AppPalette를 직접 건드리지 말고 꼭 이 함수를 통해서만 바꾼다.
import 'package:flutter/foundation.dart';
import 'app_palette.dart';
import 'profile_themes.dart';
final ValueNotifier<ProfileTheme?> globalProfileTheme = ValueNotifier(null);
void applyGlobalProfileTheme(ProfileTheme? theme) {
AppPalette.applyTemplateTheme(theme);
globalProfileTheme.value = theme;
}
+1
View File
@@ -53,6 +53,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.danger, backgroundColor: AppPalette.danger,
), ),
+1 -1
View File
@@ -131,7 +131,7 @@ class _NoticeBannerState extends State<_NoticeBanner>
Flexible( Flexible(
child: Text( child: Text(
widget.message, widget.message,
style: const TextStyle( style: TextStyle(
color: AppPalette.paper, color: AppPalette.paper,
fontSize: 13.5, fontSize: 13.5,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
+3 -1
View File
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import '../config.dart'; import '../config.dart';
import '../function/board_list_controller.dart'; import '../function/board_list_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'board_post_detail_screen.dart'; import 'board_post_detail_screen.dart';
import 'board_report_screen.dart'; import 'board_report_screen.dart';
import 'board_write_screen.dart'; import 'board_write_screen.dart';
@@ -149,7 +150,8 @@ class _BoardHomeScreenState extends State<BoardHomeScreen>
floatingActionButton: _tabController == null floatingActionButton: _tabController == null
? null ? null
: FloatingActionButton( : FloatingActionButton(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
onPressed: _openWrite, onPressed: _openWrite,
child: const Icon(Icons.edit_rounded), child: const Icon(Icons.edit_rounded),
+4 -1
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/board_post_controller.dart'; import '../function/board_post_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'title_pill.dart'; import 'title_pill.dart';
@@ -101,7 +102,8 @@ class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
const SizedBox(height: 14), const SizedBox(height: 14),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
), ),
@@ -139,6 +141,7 @@ class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)), child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
), ),
+9 -3
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/device_checkout_ledger_controller.dart'; import '../function/device_checkout_ledger_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'title_pill.dart'; import 'title_pill.dart';
@@ -77,8 +78,10 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () async { onPressed: () async {
@@ -190,7 +193,9 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
_approve(r['id']); _approve(r['id']);
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
child: const Text('승인'), child: const Text('승인'),
@@ -208,6 +213,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'), child: const Text('닫기'),
), ),
@@ -350,7 +356,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
? const BorderSide( ? const BorderSide(
color: AppPalette.warning, color: AppPalette.warning,
) )
: const BorderSide( : BorderSide(
color: AppPalette.sage, color: AppPalette.sage,
)), )),
), ),
+24 -1
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/device_checkout_controller.dart'; import '../function/device_checkout_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'title_pill.dart'; import 'title_pill.dart';
@@ -121,6 +122,16 @@ class _DeviceCheckoutRequestScreenState
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _pickStartTime, onPressed: _pickStartTime,
style: OutlinedButton.styleFrom(
foregroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
side: BorderSide(
color:
globalProfileTheme.value?.accent ??
AppPalette.ink,
),
),
icon: const Icon(Icons.play_arrow_rounded), icon: const Icon(Icons.play_arrow_rounded),
label: Text( label: Text(
_startTime == null _startTime == null
@@ -133,6 +144,16 @@ class _DeviceCheckoutRequestScreenState
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _pickEndTime, onPressed: _pickEndTime,
style: OutlinedButton.styleFrom(
foregroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
side: BorderSide(
color:
globalProfileTheme.value?.accent ??
AppPalette.ink,
),
),
icon: const Icon(Icons.stop_rounded), icon: const Icon(Icons.stop_rounded),
label: Text( label: Text(
_endTime == null _endTime == null
@@ -163,7 +184,9 @@ class _DeviceCheckoutRequestScreenState
? null ? null
: _submit, : _submit,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
+11 -3
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/friend_controller.dart'; import '../function/friend_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'title_pill.dart'; import 'title_pill.dart';
@@ -47,7 +48,10 @@ class _FriendsScreenState extends State<FriendsScreen> {
title: const Text('친구 추가'), title: const Text('친구 추가'),
content: TextField( content: TextField(
controller: idController, controller: idController,
autofocus: true,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onSubmitted: (_) => Navigator.pop(dialogContext, true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '친구 학번', labelText: '친구 학번',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -60,7 +64,8 @@ class _FriendsScreenState extends State<FriendsScreen> {
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
@@ -99,6 +104,7 @@ class _FriendsScreenState extends State<FriendsScreen> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)), child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
), ),
@@ -155,7 +161,7 @@ class _FriendsScreenState extends State<FriendsScreen> {
elevation: 0, elevation: 0,
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink, backgroundColor: globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
onPressed: _showAddFriendDialog, onPressed: _showAddFriendDialog,
icon: const Icon(Icons.person_add_rounded), icon: const Icon(Icons.person_add_rounded),
@@ -213,7 +219,9 @@ class _FriendsScreenState extends State<FriendsScreen> {
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () => onPressed: () =>
+13 -5
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../function/friend_controller.dart'; import '../function/friend_controller.dart';
import '../function/group_detail_controller.dart'; import '../function/group_detail_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'launchpad_transition.dart'; import 'launchpad_transition.dart';
import 'study_timer_screen.dart'; import 'study_timer_screen.dart';
@@ -106,7 +107,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
subtitle: Text('${f['studentId']}'), subtitle: Text('${f['studentId']}'),
trailing: ElevatedButton( trailing: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () async { onPressed: () async {
@@ -158,8 +161,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
@@ -195,6 +200,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text( child: const Text(
'나가기', '나가기',
@@ -287,7 +293,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink, backgroundColor: globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
onPressed: _showInviteSheet, onPressed: _showInviteSheet,
icon: const Icon(Icons.person_add_rounded), icon: const Icon(Icons.person_add_rounded),
@@ -323,7 +329,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
label: const Text('백품타 바로가기'), label: const Text('백품타 바로가기'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.ink, foregroundColor: AppPalette.ink,
side: const BorderSide(color: AppPalette.sage), side: BorderSide(color: AppPalette.sage),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -398,7 +404,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () => _respondJoinRequest( onPressed: () => _respondJoinRequest(
+15 -4
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/group_controller.dart'; import '../function/group_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'group_detail_screen.dart'; import 'group_detail_screen.dart';
import 'launchpad_transition.dart'; import 'launchpad_transition.dart';
@@ -57,7 +58,10 @@ class _GroupListScreenState extends State<GroupListScreen> {
children: [ children: [
TextField( TextField(
controller: nameController, controller: nameController,
autofocus: true,
maxLength: 20, maxLength: 20,
textInputAction: TextInputAction.done,
onSubmitted: (_) => Navigator.pop(dialogContext, true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '그룹 이름', labelText: '그룹 이름',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -83,7 +87,8 @@ class _GroupListScreenState extends State<GroupListScreen> {
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
@@ -180,7 +185,7 @@ class _GroupListScreenState extends State<GroupListScreen> {
elevation: 0, elevation: 0,
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
backgroundColor: AppPalette.ink, backgroundColor: globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
onPressed: _showCreateGroupDialog, onPressed: _showCreateGroupDialog,
icon: const Icon(Icons.add_rounded), icon: const Icon(Icons.add_rounded),
@@ -238,7 +243,9 @@ class _GroupListScreenState extends State<GroupListScreen> {
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ??
AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () => _respondInvite( onPressed: () => _respondInvite(
@@ -444,7 +451,11 @@ class _GroupListScreenState extends State<GroupListScreen> {
else else
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme
.value
?.accent ??
AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () => onPressed: () =>
+67
View File
@@ -0,0 +1,67 @@
// 👁️ 비밀번호 입력창 - 오른쪽 눈 아이콘을 누르고 있는 동안만 평문으로 보여준다.
// 토글형(눌러서 계속 보기) 대신 "홀드"로 만든 이유: 손을 떼는 순간 바로 다시 가려져서
// 누가 옆에서 보더라도 짧게 확인만 하고 끝낼 수 있다.
import 'package:flutter/material.dart';
class HoldToRevealPasswordField extends StatefulWidget {
final TextEditingController controller;
final String labelText;
final String? hintText;
final IconData prefixIcon;
final bool autofocus;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
const HoldToRevealPasswordField({
super.key,
required this.controller,
required this.labelText,
this.hintText,
this.prefixIcon = Icons.lock,
this.autofocus = false,
this.keyboardType,
this.textInputAction,
this.onSubmitted,
});
@override
State<HoldToRevealPasswordField> createState() =>
_HoldToRevealPasswordFieldState();
}
class _HoldToRevealPasswordFieldState extends State<HoldToRevealPasswordField> {
bool _revealed = false;
void _setRevealed(bool value) {
if (_revealed != value) setState(() => _revealed = value);
}
@override
Widget build(BuildContext context) {
return TextField(
controller: widget.controller,
obscureText: !_revealed,
autofocus: widget.autofocus,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
labelText: widget.labelText,
hintText: widget.hintText,
border: const OutlineInputBorder(),
prefixIcon: Icon(widget.prefixIcon),
suffixIcon: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _setRevealed(true),
onTapUp: (_) => _setRevealed(false),
onTapCancel: () => _setRevealed(false),
child: Icon(
_revealed ? Icons.visibility_rounded : Icons.visibility_off_rounded,
color: Colors.grey,
),
),
),
);
}
}
+45 -50
View File
@@ -6,6 +6,7 @@ import '../function/login_controller.dart';
import '../function/session_store.dart'; import '../function/session_store.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'hold_to_reveal_password_field.dart';
import 'main_dashboard.dart'; import 'main_dashboard.dart';
import 'teacher_register_screen.dart'; import 'teacher_register_screen.dart';
@@ -112,6 +113,36 @@ class _LoginScreenState extends State<LoginScreen> {
return StatefulBuilder( return StatefulBuilder(
builder: (context, setDialogState) { builder: (context, setDialogState) {
Future<void> submit() async {
String newPassword = newPwController.text.trim();
if (newPassword.isEmpty) {
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
return;
}
setDialogState(() => isUpdating = true);
final (success, message) = await _controller.changePassword(
studentId: studentId,
newPassword: newPassword,
);
setDialogState(() => isUpdating = false);
if (success) {
Navigator.pop(dialogContext);
AppNotice.show(context, message);
_navigateBasedOnRole(
role,
studentId,
name,
isDeviceMatched,
grade,
);
} else {
AppNotice.show(context, message);
}
}
return AlertDialog( return AlertDialog(
title: const Text( title: const Text(
'초기 비밀번호 변경', '초기 비밀번호 변경',
@@ -125,14 +156,13 @@ class _LoginScreenState extends State<LoginScreen> {
style: TextStyle(color: AppPalette.danger), style: TextStyle(color: AppPalette.danger),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( HoldToRevealPasswordField(
controller: newPwController, controller: newPwController,
obscureText: true, autofocus: true,
decoration: const InputDecoration( labelText: '새 비밀번호',
labelText: '새 비밀번호', prefixIcon: Icons.lock_reset,
border: OutlineInputBorder(), textInputAction: TextInputAction.done,
prefixIcon: Icon(Icons.lock_reset), onSubmitted: (_) => submit(),
),
), ),
], ],
), ),
@@ -147,36 +177,7 @@ class _LoginScreenState extends State<LoginScreen> {
backgroundColor: AppPalette.ink, backgroundColor: AppPalette.ink,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
onPressed: () async { onPressed: submit,
String newPassword = newPwController.text.trim();
if (newPassword.isEmpty) {
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
return;
}
setDialogState(() => isUpdating = true);
final (success, message) = await _controller
.changePassword(
studentId: studentId,
newPassword: newPassword,
);
setDialogState(() => isUpdating = false);
if (success) {
Navigator.pop(dialogContext);
AppNotice.show(context, message);
_navigateBasedOnRole(
role,
studentId,
name,
isDeviceMatched,
grade,
);
} else {
AppNotice.show(context, message);
}
},
child: const Text('변경하고 시작하기'), child: const Text('변경하고 시작하기'),
), ),
], ],
@@ -234,6 +235,7 @@ class _LoginScreenState extends State<LoginScreen> {
content: Text(message), content: Text(message),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('확인'), child: const Text('확인'),
), ),
@@ -254,9 +256,11 @@ class _LoginScreenState extends State<LoginScreen> {
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: Text('$title 권한 인증 (기존 방식)'), title: Text('$title 권한 인증 (기존 방식)'),
content: TextField( content: HoldToRevealPasswordField(
controller: passwordController, controller: passwordController,
obscureText: true, autofocus: true,
labelText: '비밀번호',
hintText: '비밀번호 4자리를 입력하세요',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (value) { onSubmitted: (value) {
@@ -270,10 +274,6 @@ class _LoginScreenState extends State<LoginScreen> {
AppNotice.show(context, '비밀번호가 올바르지 않습니다.'); AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
} }
}, },
decoration: const InputDecoration(
hintText: '비밀번호 4자리를 입력하세요',
border: OutlineInputBorder(),
),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -341,16 +341,11 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( HoldToRevealPasswordField(
controller: _pwController, controller: _pwController,
obscureText: true, labelText: '비밀번호',
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) => _login(), onSubmitted: (_) => _login(),
decoration: const InputDecoration(
labelText: '비밀번호',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
InkWell( InkWell(
+115 -49
View File
@@ -7,10 +7,13 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../function/dashboard_layout_controller.dart'; import '../function/dashboard_layout_controller.dart';
import '../function/profile_shop_controller.dart';
import '../function/session_store.dart'; import '../function/session_store.dart';
import '../function/student_dashboard_controller.dart'; import '../function/student_dashboard_controller.dart';
import '../models/dashboard_tile_layout.dart'; import '../models/dashboard_tile_layout.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/profile_themes.dart';
import '../theme/theme_sync.dart';
import 'admin_dashboard.dart'; import 'admin_dashboard.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'board_home_screen.dart'; import 'board_home_screen.dart';
@@ -79,6 +82,24 @@ class _MainDashboardState extends State<MainDashboard> {
if (kTileEditingEnabled) { if (kTileEditingEnabled) {
_layout.load(_tileSpecs().map((t) => t.id).toList()); _layout.load(_tileSpecs().map((t) => t.id).toList());
} }
if (_isStudent || _isTeacherOrAbove) {
_loadEquippedTheme();
}
}
// 🛍️ 프로필 꾸미기 상점에서 장착한 템플릿이 몰고 오는 테마 (없으면 기본 모습 그대로).
ProfileTheme? _equippedTheme;
// 🎨 타일 아이콘 등 강조색에 쓸 색 - 테마가 없으면 원래 쓰던 AppPalette.ink.
Color get _tileAccent => _equippedTheme?.accent ?? AppPalette.ink;
Future<void> _loadEquippedTheme() async {
final theme = await ProfileShopController.fetchEquippedTheme(_displayId);
applyGlobalProfileTheme(theme);
if (!mounted) return;
setState(() {
_equippedTheme = theme;
});
} }
@override @override
@@ -139,6 +160,15 @@ class _MainDashboardState extends State<MainDashboard> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: idController, controller: idController,
autofocus: true,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
final inputId = value.trim();
if (inputId.isNotEmpty) {
Navigator.pop(context);
_deleteUser(inputId);
}
},
decoration: InputDecoration( decoration: InputDecoration(
labelText: '학번 또는 교직원 번호', labelText: '학번 또는 교직원 번호',
hintText: '예: 201101 또는 T1001', hintText: '예: 201101 또는 T1001',
@@ -277,23 +307,28 @@ class _MainDashboardState extends State<MainDashboard> {
final List<({String id, Widget child})> tiles = []; final List<({String id, Widget child})> tiles = [];
if (_isStudent) { if (_isStudent) {
// 🌐 웹은 NFC 하드웨어에 접근할 수 없으니, 등록된 기기가 아닐 때와 똑같이
// 잠긴 모양(자물쇠 아이콘 + 회색)으로 보여주고 누르면 안내만 뜨게 한다.
final bool nfcUnlocked = widget.isDeviceMatched && !kIsWeb;
tiles.add(( tiles.add((
id: 'nfc_tag', id: 'nfc_tag',
child: _buildModernCard( child: _buildModernCard(
icon: widget.isDeviceMatched icon: nfcUnlocked ? Icons.contactless_rounded : Icons.lock_rounded,
? Icons.contactless_rounded
: Icons.lock_rounded,
title: 'NFC 태그', title: 'NFC 태그',
subtitle: widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용', subtitle: kIsWeb
color: widget.isDeviceMatched ? AppPalette.ink : AppPalette.sage, ? '스마트폰 앱 전용 기능'
onTap: widget.isDeviceMatched : (widget.isDeviceMatched ? '출석 및 폰 수거 완료' : '본인 인증 기기 전용'),
color: nfcUnlocked ? _tileAccent : AppPalette.sage,
onTap: nfcUnlocked
? () => _openPocketCheckIn(context) ? () => _openPocketCheckIn(context)
: () => AppNotice.show( : () => AppNotice.show(
context, context,
'대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.', kIsWeb
? 'NFC 태그 기능은 스마트폰 앱에서만 사용할 수 있어요.'
: '대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.',
icon: Icons.lock_rounded, icon: Icons.lock_rounded,
), ),
isActionButton: widget.isDeviceMatched, isActionButton: nfcUnlocked,
isLoading: _controller.isLoading, isLoading: _controller.isLoading,
), ),
)); ));
@@ -303,7 +338,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.fastfood_rounded, icon: Icons.fastfood_rounded,
title: '실시간 학교 상황', title: '실시간 학교 상황',
subtitle: '급식실 줄 & 매점 재고 확인', subtitle: '급식실 줄 & 매점 재고 확인',
color: AppPalette.ink, color: _tileAccent,
onTap: () => AppNotice.show( onTap: () => AppNotice.show(
context, context,
'실시간 학교 상황 페이지로 이동합니다.', '실시간 학교 상황 페이지로 이동합니다.',
@@ -317,7 +352,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.tablet_mac_rounded, icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출', title: '스마트기기 반출',
subtitle: '패드 사용 신청 (시간/목적)', subtitle: '패드 사용 신청 (시간/목적)',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => DeviceCheckoutRequestScreen( (context) => DeviceCheckoutRequestScreen(
@@ -333,7 +368,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.timer_rounded, icon: Icons.timer_rounded,
title: '백품타', title: '백품타',
subtitle: '공부 타이머 | 학년별 공부 랭킹', subtitle: '공부 타이머 | 학년별 공부 랭킹',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => StudyTimerScreen( (context) => StudyTimerScreen(
@@ -350,7 +385,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.forum_rounded, icon: Icons.forum_rounded,
title: '백판', title: '백판',
subtitle: '익명 커뮤니티 게시판', subtitle: '익명 커뮤니티 게시판',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => BoardHomeScreen(studentId: _displayId), (context) => BoardHomeScreen(studentId: _displayId),
@@ -363,7 +398,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.campaign_rounded, icon: Icons.campaign_rounded,
title: '선생님 호출', title: '선생님 호출',
subtitle: '교무실 위치 확인 및 호출', subtitle: '교무실 위치 확인 및 호출',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => TeacherCallScreen( (context) => TeacherCallScreen(
@@ -379,7 +414,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.people_alt_rounded, icon: Icons.people_alt_rounded,
title: '친구', title: '친구',
subtitle: '학번으로 친구 추가', subtitle: '학번으로 친구 추가',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => (context) =>
@@ -393,7 +428,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.groups_rounded, icon: Icons.groups_rounded,
title: '그룹 스터디', title: '그룹 스터디',
subtitle: '친구랑 그룹 만들어 같이 공부', subtitle: '친구랑 그룹 만들어 같이 공부',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => GroupListScreen( (context) => GroupListScreen(
@@ -410,7 +445,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.calendar_month_rounded, icon: Icons.calendar_month_rounded,
title: '출석 달력', title: '출석 달력',
subtitle: '연속 출석 기록 확인', subtitle: '연속 출석 기록 확인',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => StudyCalendarScreen(studentId: _displayId), (context) => StudyCalendarScreen(studentId: _displayId),
@@ -426,7 +461,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.assignment_turned_in_rounded, icon: Icons.assignment_turned_in_rounded,
title: '실시간 출석 확인', title: '실시간 출석 확인',
subtitle: '학생 제출 로그 모니터링', subtitle: '학생 제출 로그 모니터링',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => const TeacherAttendancePage(), (context) => const TeacherAttendancePage(),
@@ -439,7 +474,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.manage_accounts_rounded, icon: Icons.manage_accounts_rounded,
title: '학생 계정 관리', title: '학생 계정 관리',
subtitle: '계정 추가 및 강제 리셋', subtitle: '계정 추가 및 강제 리셋',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => const TeacherStudentManagementPage(), (context) => const TeacherStudentManagementPage(),
@@ -452,7 +487,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.tablet_mac_rounded, icon: Icons.tablet_mac_rounded,
title: '스마트기기 반출 대장', title: '스마트기기 반출 대장',
subtitle: '패드 반출 신청 승인/거절', subtitle: '패드 반출 신청 승인/거절',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => DeviceCheckoutLedgerPage( (context) => DeviceCheckoutLedgerPage(
@@ -468,7 +503,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.shield_outlined, icon: Icons.shield_outlined,
title: '백판 신고 검토', title: '백판 신고 검토',
subtitle: '학생 게시판 신고 처리', subtitle: '학생 게시판 신고 처리',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => BoardReportScreen(reviewerId: _displayId), (context) => BoardReportScreen(reviewerId: _displayId),
@@ -481,7 +516,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.event_available_rounded, icon: Icons.event_available_rounded,
title: '출석 달력 관리', title: '출석 달력 관리',
subtitle: '날짜별 선물/이벤트 등록', subtitle: '날짜별 선물/이벤트 등록',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => StudyCalendarScreen( (context) => StudyCalendarScreen(
@@ -497,7 +532,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.my_location_rounded, icon: Icons.my_location_rounded,
title: '내 위치 알리기', title: '내 위치 알리기',
subtitle: '학생 호출 확인 및 위치 등록', subtitle: '학생 호출 확인 및 위치 등록',
color: AppPalette.ink, color: _tileAccent,
onTap: () => pushLaunchpad( onTap: () => pushLaunchpad(
context, context,
(context) => TeacherLocationScreen(teacherId: _displayId), (context) => TeacherLocationScreen(teacherId: _displayId),
@@ -513,7 +548,7 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.terminal_rounded, icon: Icons.terminal_rounded,
title: '서버 DB 제어', title: '서버 DB 제어',
subtitle: '시스템 원격 초기화', subtitle: '시스템 원격 초기화',
color: AppPalette.ink, color: _tileAccent,
onTap: () => onTap: () =>
pushLaunchpad(context, (context) => const AdminDashboard()), pushLaunchpad(context, (context) => const AdminDashboard()),
), ),
@@ -524,19 +559,27 @@ class _MainDashboardState extends State<MainDashboard> {
icon: Icons.delete_sweep_rounded, icon: Icons.delete_sweep_rounded,
title: '계정 강제 삭제', title: '계정 강제 삭제',
subtitle: '학생 및 교사 DB 삭제', subtitle: '학생 및 교사 DB 삭제',
color: AppPalette.ink, color: _tileAccent,
onTap: () => _showDeleteUserDialog(context), onTap: () => _showDeleteUserDialog(context),
), ),
)); ));
tiles.add(( tiles.add((
id: 'nfc_tag_writer', id: 'nfc_tag_writer',
child: _buildModernCard( child: _buildModernCard(
icon: Icons.edit_note_rounded, icon: kIsWeb ? Icons.lock_rounded : Icons.edit_note_rounded,
title: 'NFC 태그 쓰기', title: 'NFC 태그 쓰기',
subtitle: '주머니 스티커 초기 설정', subtitle: kIsWeb ? '스마트폰 앱 전용 기능' : '주머니 스티커 초기 설정',
color: AppPalette.ink, color: kIsWeb ? AppPalette.sage : _tileAccent,
onTap: () => onTap: kIsWeb
pushLaunchpad(context, (context) => const NfcTagWriterScreen()), ? () => AppNotice.show(
context,
'NFC 태그 쓰기는 스마트폰 앱에서만 사용할 수 있어요.',
icon: Icons.lock_rounded,
)
: () => pushLaunchpad(
context,
(context) => const NfcTagWriterScreen(),
),
), ),
)); ));
} }
@@ -596,7 +639,7 @@ class _MainDashboardState extends State<MainDashboard> {
final bool isEditing = _layout.isEditing; final bool isEditing = _layout.isEditing;
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: _equippedTheme?.background ?? AppPalette.mist,
// 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴 // 🪶 큰 색상 배너 대신 작은 프로필 카드를 본문에 두므로, 앱바는 ">" 메뉴
// 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다. // 버튼(또는 편집 중 "완료" 버튼)만 올려두는 투명한 얇은 바로 둔다.
appBar: AppBar( appBar: AppBar(
@@ -645,11 +688,22 @@ class _MainDashboardState extends State<MainDashboard> {
vertical: 18, vertical: 18,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.color, color: _equippedTheme == null ? theme.color : null,
gradient: _equippedTheme == null
? null
: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_equippedTheme!.primary,
_equippedTheme!.secondary,
],
),
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: theme.color.withValues(alpha: 0.25), color: (_equippedTheme?.primary ?? theme.color)
.withValues(alpha: 0.25),
blurRadius: 16, blurRadius: 16,
offset: const Offset(0, 6), offset: const Offset(0, 6),
), ),
@@ -715,13 +769,19 @@ class _MainDashboardState extends State<MainDashboard> {
elevation: 2, elevation: 2,
child: InkWell( child: InkWell(
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: () => pushLaunchpad( onTap: () async {
context, await pushLaunchpad(
(context) => ProfileCustomizeScreen( context,
studentId: _displayId, (context) => ProfileCustomizeScreen(
studentName: _displayName, studentId: _displayId,
), studentName: _displayName,
), ),
);
// 상점에서 테마를 바꾸고 돌아왔을 수 있으니 다시 불러온다.
if (_isStudent || _isTeacherOrAbove) {
_loadEquippedTheme();
}
},
child: const Padding( child: const Padding(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
child: Icon( child: Icon(
@@ -763,8 +823,8 @@ class _MainDashboardState extends State<MainDashboard> {
), ),
), ),
if (isEditing) if (isEditing)
const Padding( Padding(
padding: EdgeInsets.only(top: 8, left: 24, right: 24), padding: const EdgeInsets.only(top: 8, left: 24, right: 24),
child: Text( child: Text(
'타일을 드래그해서 옮기고, 오른쪽 아래 손잡이로 크기를 바꿔보세요.', '타일을 드래그해서 옮기고, 오른쪽 아래 손잡이로 크기를 바꿔보세요.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -917,14 +977,14 @@ class _MainDashboardState extends State<MainDashboard> {
width: 26, width: 26,
height: 26, height: 26,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.ink, color: _tileAccent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppPalette.paper, color: AppPalette.paper,
width: 2, width: 2,
), ),
), ),
child: const Icon( child: Icon(
Icons.open_in_full_rounded, Icons.open_in_full_rounded,
size: 14, size: 14,
color: AppPalette.paper, color: AppPalette.paper,
@@ -984,9 +1044,12 @@ class _MainDashboardState extends State<MainDashboard> {
child: Ink( child: Ink(
padding: EdgeInsets.all(cardPadding), padding: EdgeInsets.all(cardPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.paper, color: _equippedTheme?.surface ?? AppPalette.paper,
borderRadius: BorderRadius.circular(cardRadius), borderRadius: BorderRadius.circular(cardRadius),
border: Border.all(color: AppPalette.sage, width: 1), border: Border.all(
color: _equippedTheme?.border ?? AppPalette.sage,
width: 1,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -995,7 +1058,7 @@ class _MainDashboardState extends State<MainDashboard> {
Container( Container(
padding: EdgeInsets.all(iconBoxPadding), padding: EdgeInsets.all(iconBoxPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.linen, color: _equippedTheme?.surfaceAlt ?? AppPalette.linen,
borderRadius: BorderRadius.circular(iconRadius), borderRadius: BorderRadius.circular(iconRadius),
), ),
child: Icon(icon, color: color, size: iconSize), child: Icon(icon, color: color, size: iconSize),
@@ -1014,7 +1077,9 @@ class _MainDashboardState extends State<MainDashboard> {
style: TextStyle( style: TextStyle(
fontSize: titleFontSize, fontSize: titleFontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppPalette.ink, color:
_equippedTheme?.onBackground ??
AppPalette.ink,
), ),
), ),
), ),
@@ -1039,7 +1104,8 @@ class _MainDashboardState extends State<MainDashboard> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: subtitleFontSize, fontSize: subtitleFontSize,
color: AppPalette.ink.withValues(alpha: 0.55), color: (_equippedTheme?.onBackground ?? AppPalette.ink)
.withValues(alpha: 0.55),
height: 1.15, height: 1.15,
), ),
), ),
@@ -1080,7 +1146,7 @@ class _DashboardMenuCard extends StatelessWidget {
label: '로그아웃', label: '로그아웃',
onTap: onLogout, onTap: onLogout,
), ),
const Divider(height: 1, color: AppPalette.sage), Divider(height: 1, color: AppPalette.sage),
_MenuRow( _MenuRow(
icon: Icons.settings_outlined, icon: Icons.settings_outlined,
label: '설정', label: '설정',
+3
View File
@@ -86,6 +86,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("확인"), child: const Text("확인"),
), ),
@@ -114,6 +115,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_controller.beginIosProximityWatch(pocketNumber); _controller.beginIosProximityWatch(pocketNumber);
@@ -139,6 +141,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("확인"), child: const Text("확인"),
), ),
+618 -21
View File
@@ -1,10 +1,14 @@
// 🎨 프로필 커스텀 화면 (UI 전용). 아직은 자리만 잡아둔 빈 화면 - 프로필 꾸미기 // 🎨 프로필 커스텀 화면 (UI 전용). 공부시간으로 적립한 캐시로 프로필 카드 테마를
// 관련 기능들이 여기에 하나씩 추가될 예정이다. // 구매/장착하는 상점. 서버 통신/상태는 lib/function/profile_shop_controller.dart가 담당한다.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/profile_shop_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/profile_themes.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart';
import 'title_pill.dart'; import 'title_pill.dart';
class ProfileCustomizeScreen extends StatelessWidget { class ProfileCustomizeScreen extends StatefulWidget {
final String studentId; final String studentId;
final String studentName; final String studentName;
@@ -14,38 +18,478 @@ class ProfileCustomizeScreen extends StatelessWidget {
required this.studentName, required this.studentName,
}); });
@override
State<ProfileCustomizeScreen> createState() => _ProfileCustomizeScreenState();
}
class _ProfileCustomizeScreenState extends State<ProfileCustomizeScreen> {
final ProfileShopController _controller = ProfileShopController();
// 🚧 첫 버전은 "기본 프로필 꾸미기"만 실제로 동작한다. 나머지 두 카테고리는
// 와이어프레임 자리만 잡아둔 준비중 상태.
String _selectedCategory = 'basic_profile';
// 👀 사기 전에도 눌러보면 바로 위 큰 타일이 그 테마로 바뀌어 보이는 미리보기 상태.
// null = 아직 직접 고르지 않음(장착된 테마를 그대로 보여줌), -1 = "기본 모습"을 명시적으로 미리보는 중.
int? _previewTemplateId;
@override
void initState() {
super.initState();
_initialize();
}
/// 상점 화면을 열 때마다 서버의 실제 장착 상태로 전역 테마를 다시 맞춰둔다
/// (딴 데서 장착 상태가 바뀌었을 수도 있는 엣지 케이스 보호용).
Future<void> _initialize() async {
await _controller.loadCatalog(widget.studentId);
if (!mounted) return;
for (final t in _controller.templates) {
if (t.equipped) {
_syncGlobalTheme(t.id);
return;
}
}
_syncGlobalTheme(null);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 지금 큰 타일에 보여줄 템플릿을 고른다. 직접 미리보기를 누른 게 있으면 그걸,
/// 없으면 실제 장착 중인 템플릿을, 그마저도 없으면 null(기본 모습)을 돌려준다.
ShopTemplate? _resolvePreviewTemplate() {
if (_previewTemplateId == -1) return null;
if (_previewTemplateId != null) {
for (final t in _controller.templates) {
if (t.id == _previewTemplateId) return t;
}
}
for (final t in _controller.templates) {
if (t.equipped) return t;
}
return null;
}
/// 🎨 지금 "체험하기/미리보기" 중인 테마로 화면 전체(배경/타일/버튼)를 물들인다.
/// 아무것도 직접 고르지 않았으면 실제 장착된 테마가 그대로 나오고, "기본 모습"을
/// 고르면 null이 되어 화면이 원래 모습으로 돌아간다 - 체험하기를 누르면 바로
/// 커스텀 화면 전체에 반영되는 효과를 _resolvePreviewTemplate() 하나로 처리한다.
ProfileTheme? get _displayTheme {
final preview = _resolvePreviewTemplate();
return preview == null ? null : profileThemes[preview.code];
}
Future<void> _handlePurchase(ShopTemplate template) async {
setState(() => _previewTemplateId = template.id);
final (success, message) = await _controller.purchase(
widget.studentId,
template.id,
);
if (!mounted) return;
AppNotice.show(
context,
message,
color: success ? AppPalette.success : AppPalette.danger,
);
}
Future<void> _handleEquip(int? templateId) async {
setState(() => _previewTemplateId = templateId ?? -1);
final (success, message) = await _controller.equip(
widget.studentId,
templateId,
);
if (success) _syncGlobalTheme(templateId);
if (!mounted) return;
AppNotice.show(
context,
message,
color: success ? AppPalette.approved : AppPalette.danger,
);
}
/// 💡 커스텀창 안에서의 "미리보기"와 달리, 실제로 장착이 끝난 뒤에는 앱 전체
/// (TitlePill, 다른 화면의 배경/카드색)도 같이 바뀌도록 전역에 반영한다.
void _syncGlobalTheme(int? equippedTemplateId) {
if (equippedTemplateId == null) {
applyGlobalProfileTheme(null);
return;
}
for (final t in _controller.templates) {
if (t.id == equippedTemplateId) {
applyGlobalProfileTheme(profileThemes[t.code]);
return;
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppPalette.mist, backgroundColor: _displayTheme?.background ?? AppPalette.mist,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink, foregroundColor: AppPalette.ink,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
title: const TitlePill('프로필 꾸미기'), title: TitlePill('프로필 꾸미기', previewTheme: _displayTheme),
), ),
body: Center( body: ListenableBuilder(
child: Padding( listenable: _controller,
padding: const EdgeInsets.all(24), builder: (context, _) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: RefreshIndicator(
onRefresh: () => _controller.loadCatalog(widget.studentId),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_buildCashBalance(),
const SizedBox(height: 20),
_buildCategoryTiles(),
const SizedBox(height: 24),
if (_controller.isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
)
else
_buildTemplateGrid(),
],
),
),
),
);
},
),
);
}
Widget _buildCashBalance() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: _displayTheme?.secondary ?? AppPalette.ink,
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
const Icon(Icons.savings_rounded, color: Colors.white, size: 22),
const SizedBox(width: 10),
const Text(
'보유 캐시',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
const Spacer(),
Text(
'${_controller.cashBalance} 캐시',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildCategoryTiles() {
final previewTemplate = _resolvePreviewTemplate();
// 📏 ListView(스크롤 영역) 안에서는 높이가 정해져 있지 않아 Row에
// stretch 정렬만 주면 자식 크기 계산이 깨진다. SizedBox로 높이를 못박아둔다.
return SizedBox(
height: 210,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: _CategoryTile(
title: '기본 프로필 꾸미기',
icon: Icons.person_rounded,
selected: _selectedCategory == 'basic_profile',
enabled: true,
onTap: () => setState(() => _selectedCategory = 'basic_profile'),
previewPrimary: previewTemplate?.primaryColor,
previewSecondary: previewTemplate?.secondaryColor,
),
),
const SizedBox(width: 12),
Expanded(
flex: 4,
child: Column(
children: [
Expanded(
child: _CategoryTile(
title: '랭킹 · 친구창 배경',
subtitle: '준비중',
icon: Icons.leaderboard_rounded,
selected: false,
enabled: false,
onTap: null,
theme: _displayTheme,
),
),
const SizedBox(height: 12),
Expanded(
child: _CategoryTile(
title: '출석 · 그룹창 표시',
subtitle: '준비중',
icon: Icons.groups_rounded,
selected: false,
enabled: false,
onTap: null,
theme: _displayTheme,
),
),
],
),
),
],
),
);
}
Widget _buildTemplateGrid() {
final templates = _controller.templates
.where((t) => t.category == _selectedCategory)
.toList();
final hasEquippedTemplate = templates.any((t) => t.equipped);
final previewId = _resolvePreviewTemplate()?.id; // null이면 "기본 모습"을 미리보는 중.
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.15,
children: [
_DefaultTemplateTile(
equipped: !hasEquippedTemplate,
previewing: previewId == null,
theme: _displayTheme,
onEquip: () => _handleEquip(null),
onPreview: () => setState(() => _previewTemplateId = -1),
),
for (final template in templates)
_TemplateTile(
template: template,
canAfford: _controller.cashBalance >= template.price,
previewing: previewId == template.id,
theme: _displayTheme,
onPurchase: () => _handlePurchase(template),
onEquip: () => _handleEquip(template.id),
onPreview: () => setState(() => _previewTemplateId = template.id),
),
],
);
}
}
class _CategoryTile extends StatelessWidget {
final String title;
final String? subtitle;
final IconData icon;
final bool selected;
final bool enabled;
final VoidCallback? onTap;
// 👀 미리보기 중인 테마 색상 - selected일 때만 의미가 있다 (기본 프로필 타일 전용).
final Color? previewPrimary;
final Color? previewSecondary;
// 🚧 "준비중" 비활성 타일의 은은한 틴트용 - 커스텀창의 체험하기 미리보기를 따라간다.
final ProfileTheme? theme;
const _CategoryTile({
required this.title,
this.subtitle,
required this.icon,
required this.selected,
required this.enabled,
required this.onTap,
this.previewPrimary,
this.previewSecondary,
this.theme,
});
@override
Widget build(BuildContext context) {
final bool hasPreview = selected && previewPrimary != null;
return Material(
color: hasPreview
? Colors.transparent
: (selected
? AppPalette.ink
: (enabled
? AppPalette.paper
: (theme?.surfaceAlt ?? AppPalette.linen))),
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: hasPreview
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [previewPrimary!, previewSecondary!],
)
: null,
border: Border.all(
color: hasPreview
? previewSecondary!
: (selected
? AppPalette.ink
: (theme?.border ?? AppPalette.sage)),
width: hasPreview ? 2 : 1,
),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Icon(
Icons.auto_awesome_rounded, icon,
size: 48, size: 22,
color: AppPalette.ink, color: selected
? Colors.white
: (enabled ? AppPalette.ink : Colors.grey),
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
const Text(
'곧 다양한 프로필 커스텀 기능이 추가될 예정이에요.',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 6),
Text( Text(
'$studentName ($studentId)', title,
style: const TextStyle(color: Colors.grey), maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: selected
? Colors.white
: (enabled ? AppPalette.ink : Colors.grey[600]),
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10, color: Colors.grey[500]),
),
],
],
),
),
),
);
}
}
class _DefaultTemplateTile extends StatelessWidget {
final bool equipped;
final bool previewing;
final ProfileTheme? theme;
final VoidCallback onEquip;
final VoidCallback onPreview;
const _DefaultTemplateTile({
required this.equipped,
required this.previewing,
required this.theme,
required this.onEquip,
required this.onPreview,
});
@override
Widget build(BuildContext context) {
final onBackground = theme?.onBackground ?? AppPalette.ink;
final borderColor = equipped
? AppPalette.approved
: (previewing ? onBackground : (theme?.border ?? AppPalette.sage));
final borderWidth = equipped || previewing ? 2.0 : 1.0;
return Material(
color: theme?.surface ?? AppPalette.paper,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onPreview,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: borderColor, width: borderWidth),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppPalette.ink,
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
'기본 모습',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: onBackground,
),
),
),
if (previewing && !equipped)
Text(
'미리보기',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: onBackground,
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: equipped
? OutlinedButton(
onPressed: null,
style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.approved,
side: const BorderSide(color: AppPalette.approved),
),
child: const Text('장착중'),
)
: OutlinedButton(
onPressed: onEquip,
style: OutlinedButton.styleFrom(
foregroundColor: theme?.accent,
side: theme == null
? null
: BorderSide(color: theme!.accent),
),
child: const Text('장착하기'),
),
), ),
], ],
), ),
@@ -54,3 +498,156 @@ class ProfileCustomizeScreen extends StatelessWidget {
); );
} }
} }
class _TemplateTile extends StatelessWidget {
final ShopTemplate template;
final bool canAfford;
final bool previewing;
final ProfileTheme? theme;
final VoidCallback onPurchase;
final VoidCallback onEquip;
final VoidCallback onPreview;
const _TemplateTile({
required this.template,
required this.canAfford,
required this.previewing,
required this.theme,
required this.onPurchase,
required this.onEquip,
required this.onPreview,
});
@override
Widget build(BuildContext context) {
final onBackground = theme?.onBackground ?? AppPalette.ink;
final borderColor = template.equipped
? AppPalette.approved
: (previewing ? onBackground : (theme?.border ?? AppPalette.sage));
final borderWidth = template.equipped || previewing ? 2.0 : 1.0;
return Material(
color: theme?.surface ?? AppPalette.paper,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onPreview,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: borderColor, width: borderWidth),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [template.primaryColor, template.secondaryColor],
),
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
template.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: onBackground,
),
),
),
if (previewing && !template.equipped)
Text(
'미리보기',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: onBackground,
),
),
],
),
const SizedBox(height: 2),
Text(
'${template.price} 캐시',
style: TextStyle(
fontSize: 12,
color: onBackground.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
if (template.equipped)
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: null,
style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.approved,
side: const BorderSide(color: AppPalette.approved),
),
child: const Text('장착중'),
),
)
else if (template.owned)
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: onEquip,
style: OutlinedButton.styleFrom(
foregroundColor: theme?.accent,
side: theme == null
? null
: BorderSide(color: theme!.accent),
),
child: const Text('장착하기'),
),
)
else
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onPreview,
style: OutlinedButton.styleFrom(
foregroundColor: theme?.accent,
side: theme == null
? null
: BorderSide(color: theme!.accent),
),
child: const Text('체험하기'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: canAfford ? onPurchase : null,
style: ElevatedButton.styleFrom(
backgroundColor: theme?.secondary ?? AppPalette.ink,
foregroundColor: Colors.white,
disabledBackgroundColor: AppPalette.sage,
),
child: const Text('구매하기'),
),
),
],
),
],
),
),
),
);
}
}
+12 -8
View File
@@ -4,6 +4,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/study_calendar_controller.dart'; import '../function/study_calendar_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'title_pill.dart'; import 'title_pill.dart';
@@ -58,6 +59,7 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'), child: const Text('닫기'),
), ),
@@ -152,7 +154,8 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor:
globalProfileTheme.value?.accent ?? AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
), ),
onPressed: () async { onPressed: () async {
@@ -310,18 +313,16 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
date.month == today.month && date.month == today.month &&
date.day == today.day; date.day == today.day;
final accentColor =
globalProfileTheme.value?.accent ?? AppPalette.ink;
return GestureDetector( return GestureDetector(
onTap: () => _onDayTap(date), onTap: () => _onDayTap(date),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: studied color: studied ? accentColor : AppPalette.paper,
? AppPalette.ink
: AppPalette.paper,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isToday color: isToday ? accentColor : AppPalette.sage,
? AppPalette.ink
: AppPalette.sage,
width: isToday ? 2 : 1, width: isToday ? 2 : 1,
), ),
), ),
@@ -359,7 +360,10 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [
_legendDot(AppPalette.ink, '공부한 날'), _legendDot(
globalProfileTheme.value?.accent ?? AppPalette.ink,
'공부한 날',
),
const SizedBox(width: 16), const SizedBox(width: 16),
_legendIcon( _legendIcon(
Icons.card_giftcard_rounded, Icons.card_giftcard_rounded,
+11 -9
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../function/study_ranking_controller.dart'; import '../function/study_ranking_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'title_pill.dart'; import 'title_pill.dart';
class StudyRankingScreen extends StatefulWidget { class StudyRankingScreen extends StatefulWidget {
@@ -126,6 +127,9 @@ class _StudyRankingScreenState extends State<StudyRankingScreen> {
r['studentId'] == widget.myStudentId; r['studentId'] == widget.myStudentId;
final int rank = (r['rank'] as num).toInt(); final int rank = (r['rank'] as num).toInt();
final medalColor = _rankColor(rank); final medalColor = _rankColor(rank);
final accentColor =
globalProfileTheme.value?.accent ??
AppPalette.ink;
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -134,14 +138,10 @@ class _StudyRankingScreenState extends State<StudyRankingScreen> {
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isMe color: isMe ? accentColor : AppPalette.paper,
? AppPalette.ink
: AppPalette.paper,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all( border: Border.all(
color: isMe color: isMe ? accentColor : AppPalette.sage,
? AppPalette.ink
: AppPalette.sage,
), ),
), ),
child: Row( child: Row(
@@ -206,14 +206,15 @@ class _StudyRankingScreenState extends State<StudyRankingScreen> {
Widget _periodChip(String value, String label) { Widget _periodChip(String value, String label) {
final bool selected = _controller.period == value; final bool selected = _controller.period == value;
final accentColor = globalProfileTheme.value?.accent ?? AppPalette.ink;
return Expanded( return Expanded(
child: ChoiceChip( child: ChoiceChip(
label: Center(child: Text(label)), label: Center(child: Text(label)),
selected: selected, selected: selected,
onSelected: (_) => _controller.setPeriod(value), onSelected: (_) => _controller.setPeriod(value),
selectedColor: AppPalette.ink, selectedColor: accentColor,
labelStyle: TextStyle( labelStyle: TextStyle(
color: selected ? Colors.white : AppPalette.ink, color: selected ? Colors.white : accentColor,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -222,12 +223,13 @@ class _StudyRankingScreenState extends State<StudyRankingScreen> {
Widget _scopeChip(String value, String label) { Widget _scopeChip(String value, String label) {
final bool selected = _controller.scope == value; final bool selected = _controller.scope == value;
final accentColor = globalProfileTheme.value?.accent ?? AppPalette.ink;
return Expanded( return Expanded(
child: ChoiceChip( child: ChoiceChip(
label: Center(child: Text(label, style: const TextStyle(fontSize: 13))), label: Center(child: Text(label, style: const TextStyle(fontSize: 13))),
selected: selected, selected: selected,
onSelected: (_) => _controller.setScope(value), onSelected: (_) => _controller.setScope(value),
selectedColor: AppPalette.ink, selectedColor: accentColor,
backgroundColor: AppPalette.mist, backgroundColor: AppPalette.mist,
labelStyle: TextStyle( labelStyle: TextStyle(
color: selected ? Colors.white : Colors.grey[600], color: selected ? Colors.white : Colors.grey[600],
+122 -78
View File
@@ -2,9 +2,11 @@
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다. // 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../function/browser_notification.dart'; import '../function/browser_notification.dart';
import '../function/study_timer_controller.dart'; import '../function/study_timer_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'launchpad_transition.dart'; import 'launchpad_transition.dart';
import 'study_preset_screen.dart'; import 'study_preset_screen.dart';
@@ -31,6 +33,9 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
with WidgetsBindingObserver { with WidgetsBindingObserver {
late final StudyTimerController _controller; late final StudyTimerController _controller;
Timer? _awayTimer; Timer? _awayTimer;
// ⌨️ 스페이스바로 시작/일시정지를 토글하기 위한 포커스 노드. 모달(뽀모도로 설정 등)이
// 닫히면 포커스를 다시 여기로 돌려줘야 스페이스바가 계속 먹힌다.
final FocusNode _keyboardFocusNode = FocusNode();
@override @override
void initState() { void initState() {
@@ -89,9 +94,34 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_awayTimer?.cancel(); _awayTimer?.cancel();
_controller.dispose(); _controller.dispose();
_keyboardFocusNode.dispose();
super.dispose(); super.dispose();
} }
/// ⌨️ 스페이스바 입력 처리 - 멈춰있으면 시작, 돌아가고 있으면 일시정지,
/// 일시정지 중이면 재개. 뽀모도로가 자동으로 공부↔휴식을 전환하는 중에는
/// 화면에서도 수동 일시정지/재개 버튼을 숨기므로 스페이스바도 똑같이 막는다.
void _handleSpacePress() {
if (_controller.isBusy) return;
if (_controller.isIdle) {
requestNotificationPermission();
_runAction(_controller.start);
return;
}
if (_controller.pomodoroEnabled && _controller.phase != null) {
return;
}
if (_controller.isRunning) {
_runAction(_controller.pause);
} else if (_controller.isPaused) {
_runAction(_controller.resume);
}
}
// 🛍️ 장착한 템플릿의 강조색 - 시작/재개처럼 "중립" 상태의 버튼/텍스트에만 쓴다.
// 일시정지(warning)/종료(danger) 같은 경고성 색은 의미가 있어서 그대로 둔다.
Color get _accentColor => globalProfileTheme.value?.accent ?? AppPalette.ink;
String _formatHMS(int totalSeconds) { String _formatHMS(int totalSeconds) {
final h = totalSeconds ~/ 3600; final h = totalSeconds ~/ 3600;
final m = (totalSeconds % 3600) ~/ 60; final m = (totalSeconds % 3600) ~/ 60;
@@ -275,6 +305,8 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
); );
}, },
); );
// 모달이 닫히면 스페이스바 토글이 다시 먹히도록 포커스를 돌려준다.
_keyboardFocusNode.requestFocus();
} }
Widget _minuteStepper({ Widget _minuteStepper({
@@ -336,91 +368,103 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
builder: (context, _) { builder: (context, _) {
final bool isRunning = _controller.isRunning; final bool isRunning = _controller.isRunning;
final bool isPaused = _controller.isPaused; final bool isPaused = _controller.isPaused;
return Scaffold( return Focus(
backgroundColor: AppPalette.mist, focusNode: _keyboardFocusNode,
appBar: AppBar( autofocus: true,
backgroundColor: Colors.transparent, onKeyEvent: (node, event) {
foregroundColor: AppPalette.ink, if (event is KeyDownEvent &&
elevation: 0, event.logicalKey == LogicalKeyboardKey.space) {
centerTitle: true, _handleSpacePress();
title: const TitlePill('백품타'), return KeyEventResult.handled;
actions: [ }
IconButton( return KeyEventResult.ignored;
icon: const Icon(Icons.tune_rounded), },
tooltip: '뽀모도로 설정', child: Scaffold(
onPressed: _showPomodoroSettings, backgroundColor: AppPalette.mist,
), appBar: AppBar(
IconButton( backgroundColor: Colors.transparent,
icon: const Icon(Icons.leaderboard_rounded), foregroundColor: AppPalette.ink,
tooltip: '학년 랭킹', elevation: 0,
onPressed: _openRanking, centerTitle: true,
), title: const TitlePill('백품타'),
], actions: [
), IconButton(
body: _controller.isLoading icon: const Icon(Icons.tune_rounded),
? const Center(child: CircularProgressIndicator()) tooltip: '뽀모도로 설정',
: Center( onPressed: _showPomodoroSettings,
child: SingleChildScrollView( ),
padding: const EdgeInsets.all(24), IconButton(
child: Column( icon: const Icon(Icons.leaderboard_rounded),
mainAxisSize: MainAxisSize.min, tooltip: '학년 랭킹',
children: [ onPressed: _openRanking,
Text( ),
_statusLabel(isRunning, isPaused), ],
style: const TextStyle( ),
fontSize: 15, body: _controller.isLoading
color: Colors.grey, ? const Center(child: CircularProgressIndicator())
), : Center(
), child: SingleChildScrollView(
if (_controller.pomodoroEnabled && padding: const EdgeInsets.all(24),
_controller.phase != null) ...[ child: Column(
const SizedBox(height: 4), mainAxisSize: MainAxisSize.min,
children: [
Text( Text(
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} ' _statusLabel(isRunning, isPaused),
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
'완료 ${_controller.completedCycles}회',
style: const TextStyle( style: const TextStyle(
fontSize: 12.5, fontSize: 15,
color: Colors.grey, color: Colors.grey,
fontWeight: FontWeight.w600,
), ),
), ),
], if (_controller.pomodoroEnabled &&
const SizedBox(height: 12), _controller.phase != null) ...[
Text( const SizedBox(height: 4),
_formatHMS(_controller.liveElapsedSeconds), Text(
style: TextStyle( '${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
fontSize: 52, '${_formatMinSec(_controller.phaseSecondsLeft)} · '
fontWeight: FontWeight.bold, '완료 ${_controller.completedCycles}회',
color: isPaused ? Colors.grey : AppPalette.ink, style: const TextStyle(
), fontSize: 12.5,
), color: Colors.grey,
const SizedBox(height: 36), fontWeight: FontWeight.w600,
_buildActionButtons(isRunning, isPaused), ),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_statBox(
'오늘',
_formatHM(_controller.todaySecondsLive),
),
const SizedBox(width: 12),
_statBox(
'이번주',
_formatHM(_controller.weekSecondsLive),
),
const SizedBox(width: 12),
_statBox(
'전체',
_formatHM(_controller.totalSecondsLive),
), ),
], ],
), const SizedBox(height: 12),
], Text(
_formatHMS(_controller.liveElapsedSeconds),
style: TextStyle(
fontSize: 52,
fontWeight: FontWeight.bold,
color: isPaused ? Colors.grey : _accentColor,
),
),
const SizedBox(height: 36),
_buildActionButtons(isRunning, isPaused),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_statBox(
'오늘',
_formatHM(_controller.todaySecondsLive),
),
const SizedBox(width: 12),
_statBox(
'이번주',
_formatHM(_controller.weekSecondsLive),
),
const SizedBox(width: 12),
_statBox(
'전체',
_formatHM(_controller.totalSecondsLive),
),
],
),
],
),
), ),
), ),
), ),
); );
}, },
); );
@@ -432,7 +476,7 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
size: 140, size: 140,
icon: Icons.play_arrow_rounded, icon: Icons.play_arrow_rounded,
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작', label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
color: AppPalette.ink, color: _accentColor,
onTap: () { onTap: () {
requestNotificationPermission(); requestNotificationPermission();
_runAction(_controller.start); _runAction(_controller.start);
@@ -456,7 +500,7 @@ class _StudyTimerScreenState extends State<StudyTimerScreen>
size: 110, size: 110,
icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
label: isPaused ? '재개' : '일시정지', label: isPaused ? '재개' : '일시정지',
color: isPaused ? AppPalette.ink : AppPalette.warning, color: isPaused ? _accentColor : AppPalette.warning,
onTap: () => onTap: () =>
_runAction(isPaused ? _controller.resume : _controller.pause), _runAction(isPaused ? _controller.resume : _controller.pause),
), ),
+20 -4
View File
@@ -75,6 +75,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
child: const Text('취소'), child: const Text('취소'),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_runAction(() => _controller.resetTestPermissions()); _runAction(() => _controller.resetTestPermissions());
@@ -98,7 +99,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
title: Text('$studentName 학생 반출 허용'), title: Text('$studentName 학생 반출 허용'),
content: TextField( content: TextField(
controller: controller, controller: controller,
autofocus: true,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
final minutes = int.tryParse(value.trim()) ?? 5;
Navigator.pop(context);
_runAction(() => _controller.allowRemoval(studentId, minutes));
},
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '허용 시간 (분)', labelText: '허용 시간 (분)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -139,7 +147,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: controller, controller: controller,
autofocus: true,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
final minutes = int.tryParse(value.trim()) ?? 10;
Navigator.pop(context);
_runAction(() => _controller.setPermissionWindow(minutes));
},
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '지금부터 허용 시간 (분)', labelText: '지금부터 허용 시간 (분)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -180,6 +195,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
child: const Text('취소'), child: const Text('취소'),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_runAction(() => _controller.dismissAll()); _runAction(() => _controller.dismissAll());
@@ -905,25 +921,25 @@ class _TimeSettingsMenuCard extends StatelessWidget {
label: '새로고침', label: '새로고침',
onTap: () => onPick('refresh'), onTap: () => onPick('refresh'),
), ),
const Divider(height: 1, color: AppPalette.sage), Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow( _TimeMenuRow(
icon: Icons.access_time_rounded, icon: Icons.access_time_rounded,
label: attendanceTimeLabel, label: attendanceTimeLabel,
onTap: () => onPick('attendance_time'), onTap: () => onPick('attendance_time'),
), ),
const Divider(height: 1, color: AppPalette.sage), Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow( _TimeMenuRow(
icon: Icons.timer_outlined, icon: Icons.timer_outlined,
label: '반출 허용 시간 설정', label: '반출 허용 시간 설정',
onTap: () => onPick('permission_window'), onTap: () => onPick('permission_window'),
), ),
const Divider(height: 1, color: AppPalette.sage), Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow( _TimeMenuRow(
icon: Icons.school_rounded, icon: Icons.school_rounded,
label: '하교 처리', label: '하교 처리',
onTap: () => onPick('dismissal'), onTap: () => onPick('dismissal'),
), ),
const Divider(height: 1, color: AppPalette.sage), Divider(height: 1, color: AppPalette.sage),
_TimeMenuRow( _TimeMenuRow(
icon: Icons.bug_report_outlined, icon: Icons.bug_report_outlined,
label: '테스트용: 허용시간 초기화', label: '테스트용: 허용시간 초기화',
+11 -9
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../data/teacher_call_schedule.dart'; import '../data/teacher_call_schedule.dart';
import '../function/teacher_call_controller.dart'; import '../function/teacher_call_controller.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/theme_sync.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'title_pill.dart'; import 'title_pill.dart';
@@ -30,6 +31,9 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
final _customPurposeController = TextEditingController(); final _customPurposeController = TextEditingController();
bool _useCustomPurpose = false; bool _useCustomPurpose = false;
// 🛍️ 장착한 템플릿의 강조색 - 선택된 칩/버튼처럼 "중립" 요소에만 쓴다.
Color get _accentColor => globalProfileTheme.value?.accent ?? AppPalette.ink;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -150,7 +154,7 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: _selectedTeacherId == null backgroundColor: _selectedTeacherId == null
? Colors.grey ? Colors.grey
: AppPalette.ink, : _accentColor,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -226,7 +230,7 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
width: 4, width: 4,
height: 16, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppPalette.ink, color: _accentColor,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@@ -318,11 +322,9 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? AppPalette.ink : AppPalette.paper, color: selected ? _accentColor : AppPalette.paper,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
border: Border.all( border: Border.all(color: selected ? _accentColor : AppPalette.sage),
color: selected ? AppPalette.ink : AppPalette.sage,
),
), ),
child: Text( child: Text(
label, label,
@@ -376,7 +378,7 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
final location = t['location'] ?? '교무실'; final location = t['location'] ?? '교무실';
final isSelected = _selectedTeacherId == t['teacherId']; final isSelected = _selectedTeacherId == t['teacherId'];
final inClass = _controller.isInClassNow(name); final inClass = _controller.isInClassNow(name);
final statusColor = inClass ? AppPalette.warning : AppPalette.ink; final statusColor = inClass ? AppPalette.warning : _accentColor;
return GestureDetector( return GestureDetector(
onTap: () => setState(() { onTap: () => setState(() {
@@ -386,10 +388,10 @@ class _TeacherCallScreenState extends State<TeacherCallScreen> {
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? AppPalette.ink : AppPalette.paper, color: isSelected ? _accentColor : AppPalette.paper,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: isSelected ? AppPalette.ink : AppPalette.sage, color: isSelected ? _accentColor : AppPalette.sage,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
@@ -150,6 +150,7 @@ class _TeacherStudentManagementPageState
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_runBulkImport(rows); _runBulkImport(rows);
@@ -236,6 +237,7 @@ class _TeacherStudentManagementPageState
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('확인'), child: const Text('확인'),
), ),
@@ -296,6 +298,7 @@ class _TeacherStudentManagementPageState
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
@@ -341,6 +344,7 @@ class _TeacherStudentManagementPageState
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.danger, backgroundColor: AppPalette.danger,
foregroundColor: Colors.white, foregroundColor: Colors.white,
+31 -17
View File
@@ -1,32 +1,46 @@
// 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고, // 🪶 검정 AppBar 대신 쓰는 둥근 제목 알약. 모든 화면이 똑같은 폰트/크기를 쓰고,
// 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다. // 화면 맨 위에 딱 붙어 보이지 않도록 위쪽에 살짝 여백을 둔다.
// 🛍️ 프로필 꾸미기 템플릿을 장착하면 이 알약 배경도 같이 바뀐다 - 앱 전체가
// 공유하는 위젯이라 여기 하나만 고치면 모든 화면의 제목 배경이 한번에 바뀐다.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../theme/profile_themes.dart';
import '../theme/theme_sync.dart';
class TitlePill extends StatelessWidget { class TitlePill extends StatelessWidget {
final String text; final String text;
const TitlePill(this.text, {super.key}); /// 🎨 프로필 꾸미기 화면의 "체험하기" 미리보기처럼, 이 화면 안에서만 임시로
/// 보여줄 테마가 있을 때 넘긴다. 넘기면 전역 장착 테마보다 이게 우선한다.
final ProfileTheme? previewTheme;
const TitlePill(this.text, {super.key, this.previewTheme});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return ValueListenableBuilder(
padding: const EdgeInsets.only(top: 8), valueListenable: globalProfileTheme,
child: Container( builder: (context, globalTheme, _) {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), final theme = previewTheme ?? globalTheme;
decoration: BoxDecoration( return Padding(
color: AppPalette.ink, padding: const EdgeInsets.only(top: 8),
borderRadius: BorderRadius.circular(999), child: Container(
), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Text( decoration: BoxDecoration(
text, color: theme?.secondary ?? AppPalette.ink,
style: const TextStyle( borderRadius: BorderRadius.circular(999),
color: Colors.white, ),
fontWeight: FontWeight.bold, child: Text(
fontSize: 15, text,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
), ),
), );
), },
); );
} }
} }