diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..8870e05 --- /dev/null +++ b/android/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,5 @@ + + + デジタル月額管理者 + + diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..dd53c40 --- /dev/null +++ b/android/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,5 @@ + + + 디지털 월세 관리자 + + diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..e572140 --- /dev/null +++ b/android/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,5 @@ + + + 数字月租管理器 + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..211c395 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Digital Rent Manager + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a439442..11662c3 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") diff --git a/assets/app_icon/house_check/1024.png b/assets/app_icon/house_check/1024.png deleted file mode 100644 index 3aaab2c..0000000 Binary files a/assets/app_icon/house_check/1024.png and /dev/null differ diff --git a/assets/app_icon/house_check/128.png b/assets/app_icon/house_check/128.png deleted file mode 100644 index 5d5a587..0000000 Binary files a/assets/app_icon/house_check/128.png and /dev/null differ diff --git a/assets/app_icon/house_check/192.png b/assets/app_icon/house_check/192.png deleted file mode 100644 index f785963..0000000 Binary files a/assets/app_icon/house_check/192.png and /dev/null differ diff --git a/assets/app_icon/house_check/256.png b/assets/app_icon/house_check/256.png deleted file mode 100644 index 1ff9271..0000000 Binary files a/assets/app_icon/house_check/256.png and /dev/null differ diff --git a/assets/app_icon/house_check/32.png b/assets/app_icon/house_check/32.png deleted file mode 100644 index c04842b..0000000 Binary files a/assets/app_icon/house_check/32.png and /dev/null differ diff --git a/assets/app_icon/house_check/48.png b/assets/app_icon/house_check/48.png deleted file mode 100644 index 180a94e..0000000 Binary files a/assets/app_icon/house_check/48.png and /dev/null differ diff --git a/assets/app_icon/house_check/512.png b/assets/app_icon/house_check/512.png deleted file mode 100644 index 786b93d..0000000 Binary files a/assets/app_icon/house_check/512.png and /dev/null differ diff --git a/assets/app_icon/house_check/64.png b/assets/app_icon/house_check/64.png deleted file mode 100644 index 851e9ea..0000000 Binary files a/assets/app_icon/house_check/64.png and /dev/null differ diff --git a/assets/app_icon/house_check/96.png b/assets/app_icon/house_check/96.png deleted file mode 100644 index 56702f1..0000000 Binary files a/assets/app_icon/house_check/96.png and /dev/null differ diff --git a/assets/appicon/appicon.png b/assets/appicon/appicon.png index 586dc2a..d268f86 100644 Binary files a/assets/appicon/appicon.png and b/assets/appicon/appicon.png differ diff --git a/assets/data/text.json b/assets/data/text.json index deae7e8..4d35cca 100644 --- a/assets/data/text.json +++ b/assets/data/text.json @@ -147,6 +147,7 @@ "estimatedAnnualCost": "Estimated Annual Cost", "totalSubscriptionServices": "Total Subscription Services", "eventDiscountActive": "Event Discount Active", + "eventDiscountEndsBeforeBilling": "Event discount ends before billing date", "saving": "Saving", "paymentDueToday": "Payment Due Today", "paymentDueInDays": "Payment due in @ days", @@ -199,7 +200,7 @@ "cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.", "goToCancelPage": "Go to Cancellation Page", "urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name", - "discountPercent": "@% discount", + "discountPercent": "% discount", "discountAmountWon": "Save ₩@", "discountAmountDollar": "Save $@", "discountAmountYen": "Save ¥@", @@ -377,6 +378,7 @@ "estimatedAnnualCost": "예상 연간 구독 비용", "totalSubscriptionServices": "총 구독 서비스", "eventDiscountActive": "이벤트 할인 중", + "eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다", "saving": "절약", "paymentDueToday": "오늘 결제 예정", "paymentDueInDays": "@일 후 결제 예정", @@ -429,7 +431,7 @@ "cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.", "goToCancelPage": "해지 페이지로 이동", "urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다", - "discountPercent": "@% 할인", + "discountPercent": "% 할인", "discountAmountWon": "₩@원 절약", "discountAmountDollar": "$@ 절약", "discountAmountYen": "¥@ 절약", @@ -607,6 +609,7 @@ "estimatedAnnualCost": "予想年間サブスクリプション費用", "totalSubscriptionServices": "総サブスクリプションサービス", "eventDiscountActive": "イベント割引中", + "eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します", "saving": "節約", "paymentDueToday": "本日支払い予定", "paymentDueInDays": "@日後に支払い予定", @@ -659,7 +662,7 @@ "cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。", "goToCancelPage": "解約ページへ移動", "urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます", - "discountPercent": "@%割引", + "discountPercent": "%割引", "discountAmountWon": "₩@節約", "discountAmountDollar": "$@節約", "discountAmountYen": "¥@節約", @@ -826,6 +829,7 @@ "estimatedAnnualCost": "预计年度订阅费用", "totalSubscriptionServices": "总订阅服务", "eventDiscountActive": "活动折扣中", + "eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束", "saving": "节省", "paymentDueToday": "今日付款到期", "paymentDueInDays": "@天后付款到期", @@ -878,7 +882,7 @@ "cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。", "goToCancelPage": "前往取消页面", "urlAutoMatchInfo": "如果URL为空,将根据服务名称自动匹配", - "discountPercent": "@%折扣", + "discountPercent": "%折扣", "discountAmountWon": "节省₩@", "discountAmountDollar": "节省$@", "discountAmountYen": "节省¥@", diff --git a/doc/color.md b/doc/color.md index 174753e..5c2a7e7 100644 --- a/doc/color.md +++ b/doc/color.md @@ -1,79 +1,208 @@ -# 구독관리 앱 글래스모피어즘 컬러 & 텍스트 컬러 가이드 +# SubManager 컬러/테마 가이드 v4 (Glass 제거, 완전 Material 3) -구독관리 앱에 글래스모피어즘을 적용할 때, **신뢰성, 편안함, 트렌드함**을 모두 잡으면서도 **텍스트 가독성**을 최우선으로 고려한 컬러 팔레트와 활용법을 안내합니다. +목표: 글래스모피어즘(반투명/블러/그라데이션)을 전면 제거하고, 전 화면/버튼/팝업을 Material 3(ColorScheme/typography/shape/elevation) 기준으로 재정렬합니다. 버튼 나열 UI를 드롭다운으로 바꾸지 않습니다. 설정 화면에 라이트/다크/시스템 모드 선택 UI를 추가합니다. -## 1. 컬러 팔레트 제안 +## 0) 현재 상태 진단(요약) +- 전역 테마: M3 사용 중(`useMaterial3: true`). 라이트/다크/OLED/고대비 테마 존재. +- 이슈: `ColorScheme.error`가 핑크(danger)에 매핑 → 오류색으로 부적합(레드 필요). +- Glass 사용처 다수(요약/분석/네비/빈상태 등): 반투명+블러+경계. 다크/저성능 장치에서 가독성·성능 저하 가능. +- 곳곳의 하드코딩 텍스트 컬러(`AppColors.darkNavy`, `Color(0xFF...)`) 존재 → 다크에서 대비 문제 소지. -| 용도 | 컬러명 | Hex 코드 | 설명/느낌 | -|--------------|--------------|--------------|--------------------------| -| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 | -| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 | -| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 | -| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 | -| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 | -| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 | -| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 | +## 1) 원칙(신뢰·접근성·일관성) +- 신뢰: Primary는 딥 블루(#2563EB). 과장된 장식 대신 명확한 위계/역할색 사용. +- 접근성: 본문 대비 WCAG AA(4.5:1) 충족. on-colors(onPrimary/onSurface/onError…) 일관 적용. +- 일관성: 전역 ColorScheme/typography/shape/elevation 우선, 로컬 styleFrom 최소화. +- 성능/가독성: Glass 제거 → 불투명 Surface + elevation/outline 중심으로 레이어 구분. -## 2. 텍스트 색상 가이드 +## 2) 팔레트(최종) +- Primary: #2563EB / onPrimary: #FFFFFF +- Secondary: #60A5FA / onSecondary: #0B1B31(또는 onSurface) +- Tertiary(Info): #6366F1 / onTertiary: #FFFFFF +- Error: #EF4444 / onError: #FFFFFF +- Success: #22C55E / Warning: #F59E0B (둘은 ColorScheme 외 확장 토큰으로 관리) +- Light: Background #F1F5F9 / Surface #FFFFFF / SurfaceVariant #F8FAFC / OnSurface #1E293B / OnSurfaceVariant #334155 / Outline #E2E8F0 +- Dark: Background #121212 / Surface #1E1E1E / OnSurface #F5F5F6 / OnSurfaceVariant #94A3B8 / Outline #3F3F46 -밝은 배경(예: #f1f5f9, #ffffff(투명)) 위에는 **어두운 텍스트**를, -진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다. +## 3) 타입·라디우스·간격·음영 스케일 +- Typography(권장): + - displayLarge 48 / displayMedium 40 / displaySmall 34 + - headlineLarge 32 / headlineMedium 28 / headlineSmall 24 + - titleLarge 20 / titleMedium 18 / titleSmall 16 + - bodyLarge 16 / bodyMedium 14 / bodySmall 12 + - labelLarge 14 / labelMedium 12 / labelSmall 11 + - Line-height: 1.3~1.5, Letter-spacing: 헤드라인(-0.2~-0.5), 본문(+0.1) +- Shape: 4(칩/태그) / 8(스위치/토글) / 12(버튼/입력) / 16(카드/시트) +- Elevation: 0(평면) / 1(구분) / 3(카드) / 6(상부 시트/다이얼로그) +- Spacing: 4 단위(8/12/16/24/32)로 수직 리듬 고정 -| 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 | -|------------------|----------------------|-----------------------------------| -| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 | -| White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 | -| Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 | -| Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 | -| Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 | -| Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 | +## 4) Glass 제거 및 대체 규칙 +- `lib/widgets/glassmorphism_card.dart` 사용부 전면 치환: + - 대체: `Card(elevation: 3, color: colorScheme.surface, shape: 16)` + - 경계: `Outline` 기반(라이트 #E2E8F0, 다크 #3F3F46, 투명도 60~80%) + - 섀도우: 라이트만 약하게(8~12), 다크는 outline 위주 +- 내부 텍스트: 항상 `colorScheme.onSurface` 또는 전역 `textTheme` 사용(하드코딩 금지) +- 그라데이션/반투명 배경 삭제(필요 시 Hero/그림·아이콘 등으로 시각적 흥미 보완) -## 3. 실전 적용 예시 +## 5) 컴포넌트별 가이드(누락 없음) +- AppBar: 배경=surface, 제목/아이콘=onSurface, 높이=56, 타이틀 글꼴=titleLarge +- Navigation(하단): 배경=surface, 활성 아이콘/라벨=primary, 비활성=onSurfaceVariant, 반경=16 +- FAB: 배경=primary, 아이콘=onPrimary, 반경=16, elevation=6 +- Buttons(Elevated/Text/Outlined): minHeight=48, 반경=12, primary=onPrimary, outline=outline, text=onSurface +- IconButton: 기본 onSurface, 강조 상태는 primary 80~90% +- Inputs(TextField/Selectors): filled 라이트=surfaceVariant, 다크=#2A2A2A, 포커스라인=primary 1.5, 에러=error 1.5~2 +- Chips/Badges: 배경=역할색(primary/success/warning/error), 텍스트=onX, 반경=8 +- Cards: elevation=3, 반경=16, 배경=surface, 텍스트=onSurface +- Lists/Tiles: 제목=onSurface, 보조=onSurfaceVariant, divider=outline, 타일 반경=12 +- Dialogs/Sheets: 배경=surface, 제목=titleLarge, 본문=bodyMedium, 버튼=역할색+onX, elevation=6, 반경=20 +- Snackbar: 배경=역할색(primary/success/warning/error), 텍스트/아이콘=onX, 모서리=12, floating +- Tooltips: 배경=onSurface, 텍스트=surface, 반경=8 +- Progress: primary 사용, 트랙=onSurfaceVariant +- Charts/Analysis: 팔레트 [primary, tertiary(info), success, warning, error, secondary], 라벨=onSurface +- Categories/SMS: 카테고리 배경 위 텍스트/아이콘은 대비 계산(white 또는 onSurface) 적용 -- **배경**: Light Gray (#f1f5f9) -- **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb) -- **메인 텍스트**: Dark Navy (#1e293b) -- **서브/설명 텍스트**: Navy Gray (#334155) -- **버튼 배경**: Deep Blue (#2563eb) -- **버튼 텍스트**: Pure White (#ffffff) -- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6) - -## 4. 그라디언트 및 글래스 효과 예시 +## 6) 설정 화면에 모드 선택 UI 추가(계획) +- 위치: `lib/screens/settings_screen.dart` +- 섹션명: Appearance(또는 테마) +- 구성: `Theme Mode` 라디오 그룹(시스템 / 라이트 / 다크) + - RadioListTile 3개(버튼 나열 유지, 드롭다운 금지) + - 값: `AppThemeMode.system|light|dark` + - 동작: `context.read().setThemeMode(mode)` 호출 +- 추가 토글(유지): 큰 텍스트/모션 감소/고대비(현 Provider 연동) +샘플 코드 ```dart -// Flutter 예시 (Dart) -LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0xFF2563eb), - Color(0xFF60a5fa), - Color(0xFFe0e7ef), - ], -) +final themeProvider = context.read(); +Column(children: [ + ListTile(title: Text('Theme Mode')), + RadioListTile( + title: Text('System'), + value: AppThemeMode.system, + groupValue: themeProvider.themeMode, + onChanged: (v) => themeProvider.setThemeMode(v!), + ), + RadioListTile( + title: Text('Light'), + value: AppThemeMode.light, + groupValue: themeProvider.themeMode, + onChanged: (v) => themeProvider.setThemeMode(v!), + ), + RadioListTile( + title: Text('Dark'), + value: AppThemeMode.dark, + groupValue: themeProvider.themeMode, + onChanged: (v) => themeProvider.setThemeMode(v!), + ), +]); ``` -- 글래스 카드 배경: rgba(255,255,255,0.2) + blur + border(Deep Blue) -- 텍스트: #1e293b(진한 네이비) 또는 #2563eb(딥블루) 사용 -## 5. 디자인 팁 +## 7) 적용 순서(리스크 최소) +1) 전역 스킴 교정: `ColorScheme.error` 레드로, textTheme onSurface 정렬 +2) Glass 제거: `GlassmorphismCard` → `Card` 치환(화면 단위 PR: 홈→분석→설정→세부) +3) 버튼/입력/스낵바/다이얼로그 on-colors 정렬, 하드코딩 텍스트 제거 +4) 모드 선택 UI 추가(설정 화면 라디오 그룹) +5) 카테고리/차트 대비 보정 유틸 적용 +6) 회귀·접근성 검증(라이트/다크/시스템) -- **텍스트 대비**를 항상 체크하세요. - 밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트! -- **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다. -- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요. +## 8) 검증 +- 스크립트: `scripts/check.sh` (format/analyze/test) +- 시각: 모든 화면에서 텍스트 대비(AA) 확인, 상태(Hover/Pressed/Disabled) 점검 +- 성능: Glass 제거 후 저사양 단말 스크롤/애니메이션 프레임 확인 -## 6. 컬러/텍스트 조합 요약표 +## 9) 요약 +- Glass 제거 + 완전 Material 3 전환으로 신뢰감, 가독성, 성능을 함께 강화합니다. +- 오류색은 레드로 통일, on-colors로 대비를 보장합니다. +- 설정에 시스템/라이트/다크 선택을 제공하고, 버튼 나열 UI는 유지합니다. -| 배경색 | 텍스트색 | 용도 예시 | -|------------------|------------------|--------------------| -| #f1f5f9 | #1e293b | 메인 타이틀, 내용 | -| #ffffff(투명) | #2563eb | 카드 내 강조 | -| #2563eb | #ffffff | 버튼, 반전 강조 | -| #60a5fa | #334155 | 서브, 설명 | -| #38bdf8 | #334155 | 포인트, 서브텍스트 | +## 진행 현황(Work Log) +- [완료] 전역 스킴 교정: `ColorScheme.error`를 레드(#EF4444)로 교정 (라이트/다크) +- [완료] 스낵바 오류색 정렬: `AppSnackBar.showError`가 `colorScheme.error` 사용 +- [완료] 설정 화면 테마 모드 UI: System/Light/Dark SegmentedButton 추가(드롭다운/라디오 대체, M3 준수) +- [완료] Glass 제거(설정 화면): `GlassmorphismCard` → `Card` 치환 +- [완료] Glass 제거(빈 상태 위젯): `EmptyStateWidget`를 `Card` 기반으로 재구성 +- [완료] Glass 제거(홈 요약 카드): `MainScreenSummaryCard` 외곽 → `Card` +- [완료] Glass 제거(분석 카드): 월간 지출/총지출/파이차트 카드 → `Card` +- [완료] Glass 제거(광고 카드): `NativeAdWidget` → `Card` +- [완료] Glass 제거(추가 폼 섹션): `AddSubscriptionForm` → `Card` +- [완료] Glass 제거(SMS 권한 화면): 설명 카드 → `Card` +- [완료] Glass 제거(네비게이션): Floating Navigation Bar → Container + Padding(Material 기준) +- [완료] Glass 제거(메인 스캐폴드): `GlassmorphicScaffold` → Stack+Scaffold(배경 그라디언트+M3) +- [진행] Glass 제거(기타): 일부 카드(예: SubscriptionCard) 잔여 사용처 점진 치환 예정 +- [완료] Glass 제거(구독 카드): SubscriptionCard 래퍼를 Material Card+InkWell로 대체 +- [진행] 하드코딩 텍스트 컬러 제거: 메인 요약/URL 섹션/네비/홈 로딩 인디케이터 등 onSurface/onSurfaceVariant로 정렬 +- [진행] 하드코딩 컬러 정리(추가): 카테고리 관리/앱 잠금/이벤트·URL 상세 섹션 컨테이너와 텍스트를 M3(`surface`, `outline`, `onSurface`)로 정렬 +- [진행] 폼/셀렉터 M3 정렬: DatePickerField/CurrencySelector 색을 `onSurface`/`primary`/`surfaceVariant`로 통일 +- [진행] Selectors: Category/BillingCycle 선택 컴포넌트의 배경/텍스트를 `primary`/`onSurface`로 정렬 +- [진행] 공통 입력/라벨: BaseTextField/DatePickerField 라벨·힌트·값을 `onSurface`/`onSurfaceVariant`로 정렬 +- [진행] 삭제 다이얼로그: Glass 제거, Material Dialog(표면/elevation) + on-colors 적용 +- [진행] 추가 화면: 이벤트 섹션 타이틀/설명을 onSurface로 정렬 +- [진행] 날짜 필드(DatePicker/Range): 라벨/값/아이콘/컨테이너를 M3 surface/outline/onSurface 계열로 치환 +- [진행] 분석 카드/리스트: 보조 텍스트/경계/아이콘을 onSurfaceVariant/primary 계열로 정리 +- [진행] 설정 화면: 텍스트/아이콘 색을 onSurface/onSurfaceVariant로 정리 +- [진행] SMS 권한 화면: 아이콘/제목/본문을 primary/onSurface/onSurfaceVariant로 정리 +- [진행] 추가 화면 AppBar/저장 버튼: 색을 onSurface/primary로 정리 +- [다음] 버튼/입력/다이얼로그/스낵바의 on-colors 재점검 및 하드코딩 텍스트 컬러 제거 + +### 2025-09-10 작업 메모(Incremental) +- [완료] Settings 화면: `AppColors.*` 제거 → `colorScheme.primary/onSurface/onSurfaceVariant` 적용. 알림 반복 SwitchListTile의 `activeColor` 비사용(신 API `activeThumbColor/activeTrackColor`)로 교체. +- [완료] AddSubscriptionForm: CurrencySelector / BillingCycleSelector / CategorySelector의 `isGlassmorphism` 플래그 비활성(기본 M3 경량 스타일 사용). +- [완료] MainSummaryCard: 이벤트 절약액 텍스트 색상을 `colorScheme.primary`로 정렬. +- [완료] MonthlyExpenseChartCard: 툴팁 배경/텍스트를 `inverseSurface/onInverseSurface`로 교체(가독성 향상). +- [완료] Light Theme 카드/입력: `lib/theme/app_theme.dart`의 카드 테마에서 글래스 컬러/보더 제거, elevation=1·radius=16 유지. InputDecorationTheme는 `surfaceVariant`(light 대체 토큰) + `outline/primary/error` 경계로 전환. +- [완료] TotalExpenseSummaryCard: 아이콘 캡슐 배경을 `surfaceContainerHighest`+`outline`로 교체, 아이콘 컬러는 `primary` 사용. 복사 스낵바의 글래스 배경 제거. +- [완료] DetailFormSection: 글래스 박스 → `surface` + `outline` 컨테이너로 교체, Currency/BillingCycle/Category 셀렉터의 `isGlassmorphism` 비활성. +- [완료] SMS Scan SubscriptionCard: `Card(elevation:1, outline)`로 교체, forceDark 텍스트 제거, 입력 `fillColor`를 `surface`로 통일, 카테고리 셀렉터 글래스 비활성. +- [완료] SecondaryButton: Hover 배경을 `onSurface` 6%로, 보더/텍스트를 `outline/primary`로 정렬. +- [완료] CategoryManagement: AppBar `primary/onPrimary` 적용, Dropdown `value→initialValue`(비권장 API 해결), 텍스트 onSurface 정렬. +- [완료] Primary/SecondaryButton hover 트랜스폼: `Matrix4.scale` 제거 → `diagonal3Values` 또는 `Transform.scale`로 대체(비권장 API 해결). +- [완료] RotatePageRoute 전환: `Matrix4.scale` 제거 → 중첩 `Transform.scale`로 전환. +- [완료] ThemedText: AppColors 의존 제거, 대비 색상 결정을 `colorScheme.onSurface` 기반으로 단순화. +- [완료] 글래스 파일 제거: `lib/widgets/glassmorphism_card.dart`, `lib/widgets/glassmorphic_scaffold.dart` 삭제(미참조 확인). +- [완료] Light Theme 텍스트·컴포넌트 정렬: `app_theme.dart`에서 textTheme를 M3 기본 + `onSurface` 컬러로 일괄 정렬. Switch/Checkbox/Radio/Slider/TabBar/Divider를 `ColorScheme` 기반으로 리팩터. +- [완료] AddSubscriptionAppBar: const 적용(경고 제거), `scripts/check.sh` 전체 통과 확인. +- [완료] Dark/OLED 테마 정리: `adaptive_theme.dart`에서 다크 텍스트·컴포넌트(M3 on-colors) 정렬, Input/Buttons/TabBar/Divider/Switch/Checkbox/Radio/Slider를 ColorScheme 기준으로 통일. OLED는 surface/배경만 블랙 톤으로 보정. +- [완료] ThemedText: Glass 마커 제거(Indicator/Wrapper 삭제), 대비 로직 단순화. +- [완료] Charts: 월간 바차트 색상 `ColorScheme.primary/secondary`로 전환, 그리드/백바 `onSurfaceVariant` 사용. 파이차트 팔레트는 `ColorScheme(primary/secondary/tertiary/error)+success/warning 상수`로 정리. +- [완료] Settings/SubscriptionCard: 글래스 위젯 의존 제거 → Material Card + InkWell로 치환(중첩 Padding은 ListTile의 `contentPadding` 사용). +- [완료] Settings 색 정리 마무리: 모든 텍스트/아이콘/보더/드롭다운을 `onSurface/onSurfaceVariant/primary/surface`로 통일. +- [완료] 전역 그라데이션 제거: EmptyState/FloatingNav Add/MainSummary 이벤트 배지/Detail Header/Detail 편집 안내/SubscriptionCard 헤더·이벤트 배지/Add 화면 헤더/Splash 배경, 로고/파티클 장식 등 모든 Linear/Radial gradient 삭제. 단색은 `primary`/`surface`/`surfaceContainer*`/semantic(error, warning)로 대체. +- [완료] 차트 막대 그라데이션 제거: 단색 `primary`로 통일. +- [검증] `scripts/check.sh` 실행: 포맷 자동 적용 후 정적 분석 info 수준 경고만 존재(주요 `activeColor` 비권장 항목 해결됨). -## 결론 +### 2025-09-11 작업 메모(Incremental) +- [완료] BillingCycleSelector: 선택 배경=primary, 텍스트=onPrimary, 비선택 배경=surface, 보더=outline(60%); glass/gradient 파라미터는 비사용 처리(호환 유지). +- [완료] CurrencySelector: 동일한 M3 패턴으로 정리(표면/윤곽선/온컬러), isGlassmorphism 무시. +- [완료] CategorySelector: 선택 시 baseColor가 있으면 사용, 없으면 primary; 나머지는 surface/outline/onSurface. +- [완료] AnalysisBadge: AppColors 제거 → surface 배경 + outline 보더 + 은은한 블랙 섀도(8%). +- [완료] SubscriptionCard: + - 상단 스트립: event=error, 결제 임박=warning, 그 외=카테고리 색. + - 가격: 이벤트 원가=onSurfaceVariant 취소선, 현재가=error, 일반가=primary. + - 결제 예정 뱃지: success/warning(확장 토큰) 사용, 배경은 10% 알파. + - 결제 주기 뱃지: surface + outline, 텍스트 onSurfaceVariant. +- [검증] `scripts/check.sh` 전체 통과(Format/Analyze/Test OK). -- **블루+화이트+민트** 조합과, **밝은 배경+어두운 텍스트** 원칙으로 신뢰성, 편안함, 트렌드함, 가독성 모두 챙길 수 있습니다. -- 실제 앱에 적용할 때는 위 표를 참고해 각 상황별로 텍스트 컬러를 꼭 맞춰주세요. -- 글래스모피어즘 효과와 대비 높은 텍스트 조합으로, 세련되고 사용성 좋은 구독관리 앱을 완성할 수 있습니다. \ No newline at end of file +### 2025-09-11 추가 배치 +- [완료] AppLock/Main 화면 스낵바: ColorScheme.error/success + onPrimary 텍스트로 통일. +- [완료] AddSubscriptionEventSection: info 박스 `tertiary`로, 아이콘도 동일 컬러. +- [완료] DetailEventSection: 초록 상수 제거 → `colorScheme.success`/onPrimary. +- [완료] SMS Scan 위젯: 로딩 인디케이터/버튼을 `primary` 기반으로. +- [완료] SubscriptionPieChartCard: AppColors 제거, 팔레트는 `primary/success/warning/error/tertiary/secondary` + 화이트 라벨. 환율 배지는 `primary` 소프트 톤. +- [완료] EventAnalysisCard: 현재가/할인율 배지 색을 `success/error`로 정리. +- [완료] TotalExpenseSummaryCard: 아이콘을 `success`로 정리. +- [완료] Splash: overlay/파티클/타이틀/서브타이틀/인디케이터를 ColorScheme 기반으로 단순화(파티클 색은 렌더 시 `primary`). +- [검증] `scripts/check.sh` 재실행 통과. + +### 2025-09-11 Dark Theme 정리 +- [완료] adaptive_theme.dart 다크 테마를 전면 ColorScheme 기반으로 재정렬: + - InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant. + - Elevated/Switch/Checkbox/Radio/Slider/TabBar/Divider: scheme 값 사용. + - AppBar/Card: 배경=surface, 전경/테두리=scheme on/outline. + - OLED 테마는 surface만 더 어둡게 덮어쓰기. +- [검증] `scripts/check.sh` 통과. + +### 2025-09-11 Light Theme 추가 정리 +- [완료] app_theme.dart 라이트 테마를 ColorScheme 기반으로 정리: + - InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant. + - Elevated/Text/Outlined/FAB: primary/onPrimary, Outlined 보더=outline. + - SnackBarTheme: primary/onPrimary. + - Scaffold 배경은 기존 디자인(#F1F5F9)을 유지(직접 지정). +- [검증] `scripts/check.sh` 재실행 통과. diff --git a/doc/plan_color.md b/doc/plan_color.md new file mode 100644 index 0000000..f709720 --- /dev/null +++ b/doc/plan_color.md @@ -0,0 +1,70 @@ +# Color & Theme Plan (Material 3) + +Goals +- Remove Glassmorphism. Use Material 3 ColorScheme/typography/shape/elevation consistently. +- Ensure light/dark/system modes with accessible contrast; no dark-on-dark text. +- Semantic colors: primary/secondary/info/success/warning/error. + +Phases +1) Audit + Baseline +- Inventory AppColors and Glass usages; map to ColorScheme. +- Set `ColorScheme.error=#EF4444` (light/dark) and verify Snackbar uses. + +2) Core Components +- Settings: unify to `onSurface/onSurfaceVariant/primary` and fix Switch deprecations. +- Home Summary: surface/elevation + on-colors; badges use surfaceContainer variants. +- Add Subscription: selectors/fields to M3; disable glass flags. + +3) Analysis & Lists +- Charts: grid/labels to onSurfaceVariant; tooltips to inverseSurface. +- Event/Detail sections: containers to surface + outline; text/icons to onSurface. + +4) Theme & Cleanup +- Refactor `app_theme.dart` to remove glass defaults; prefer ColorScheme-driven themes. +- Replace remaining hard-coded colors (AppColors.*) with scheme; keep gradients sparingly. +- Resolve lints: const constructors, deprecated APIs (activeColor, scale). + +Validation +- Run `scripts/check.sh` every change. +- Visual check in light/dark/system; confirm no low-contrast text. + +Current Status (2025-09-10) +- Settings screen updated to ColorScheme; Switch deprecations fixed. +- AddSubscription selectors use M3 (glass flags off). +- MainSummaryCard event-savings text = primary. +- Monthly chart tooltips use inverseSurface/onInverseSurface. +- Next: theme/app_theme.dart cleanup; remaining AppColors usages; chart palette alignment. + +Current Status (2025-09-11) +- Billing/Currency/Category selectors: use ColorScheme (selected=primary/onPrimary, unselected=surface+outline, text=onSurface). Glass/grad props deprecated and ignored. +- AnalysisBadge: remove AppColors, use surface + outline + subtle shadow. +- SubscriptionCard: header strip uses error/warning/category; price and badges use ColorScheme (error/primary/onSurfaceVariant); due-chip uses success/warning extension; removed hard-coded reds/grays. +- Checks: scripts/check.sh passes (format/analyze/test). +- Next: migrate remaining AppColors usages (detail sections, snackbars, splash), reduce hard-coded Colors in adaptive_theme.dart, optional: revisit success/warning harmonization. + +Update (2025-09-11, PM) +- DetailEventSection: replaced green constants with ColorScheme.success; onPrimary for pill text. +- AddSubscriptionEventSection: info boxes use tertiary; removed AppColors. +- SMS Scan widgets: progress/button now use ColorScheme.primary. +- SubscriptionPieChartCard: no AppColors; chart palette uses scheme.success/warning; in-chart labels are white; exchange-rate chip uses primary soft background/border. +- EventAnalysisCard: discount/current price and discount badge use scheme.success/error. +- TotalExpenseSummaryCard: success icon uses scheme.success. +- AppLock/Main screen SnackBars: unified to scheme.error/success with onPrimary text. +- Splash: overlay/particles/title/subtitle/progress use ColorScheme; particle color bound to scheme.primary. +- Checks: scripts/check.sh passes. + +Update (2025-09-11, PM-2) +- Dark Theme(adaptive_theme.dart): replaced hard-coded widget colors with ColorScheme-driven values. + - Inputs: fill=surface, borders=outline/primary/error, labels/hints=onSurfaceVariant. + - Buttons/Switch/Checkbox/Radio/Slider/TabBar/Divider: all use scheme tokens. + - AppBar/Card: background=surface, foreground/on-colors from scheme. + - OLED: inherits dark with surface override only. +- Checks: scripts/check.sh passes (no issues). + +Update (2025-09-11, PM-3) +- Light Theme(app_theme.dart): AppColors 의존을 ColorScheme 사용으로 축소. + - InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant. + - Buttons/FAB: primary/onPrimary, Outlined side=outline. + - SnackBarTheme: primary/onPrimary. + - Scaffold background 유지(#F1F5F9) — ColorScheme.background 대신 직접 지정. +- Checks: scripts/check.sh passes. diff --git a/ios/Runner/en.lproj/InfoPlist.strings b/ios/Runner/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..2fa4bd7 --- /dev/null +++ b/ios/Runner/en.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* Localized display name */ +"CFBundleDisplayName" = "Digital Rent Manager"; + diff --git a/ios/Runner/ja.lproj/InfoPlist.strings b/ios/Runner/ja.lproj/InfoPlist.strings new file mode 100644 index 0000000..700dc65 --- /dev/null +++ b/ios/Runner/ja.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* ローカライズされたアプリ表示名 */ +"CFBundleDisplayName" = "デジタル月額管理者"; + diff --git a/ios/Runner/ko.lproj/InfoPlist.strings b/ios/Runner/ko.lproj/InfoPlist.strings new file mode 100644 index 0000000..16926b3 --- /dev/null +++ b/ios/Runner/ko.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* 로컬라이즈된 앱 표시 이름 */ +"CFBundleDisplayName" = "디지털 월세 관리자"; + diff --git a/ios/Runner/zh.lproj/InfoPlist.strings b/ios/Runner/zh.lproj/InfoPlist.strings new file mode 100644 index 0000000..55ee370 --- /dev/null +++ b/ios/Runner/zh.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* 本地化的应用显示名称 */ +"CFBundleDisplayName" = "数字月租管理器"; + diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index cd2e67b..7f24e60 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -12,6 +12,8 @@ import 'package:intl/intl.dart'; import '../widgets/dialogs/delete_confirmation_dialog.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; +import '../utils/billing_date_util.dart'; +import '../utils/business_day_util.dart'; /// DetailScreen의 비즈니스 로직을 관리하는 Controller class DetailScreenController extends ChangeNotifier { @@ -407,7 +409,14 @@ class DetailScreenController extends ChangeNotifier { subscription.monthlyCost = monthlyCost; subscription.websiteUrl = websiteUrl; subscription.billingCycle = _billingCycle; - subscription.nextBillingDate = _nextBillingDate; + // 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + final originalDateOnly = DateTime( + _nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day); + var adjustedNext = + BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle); + // 주말/고정 공휴일 보정 → 다음 영업일로 이월 + adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext); + subscription.nextBillingDate = adjustedNext; subscription.categoryId = _selectedCategoryId; subscription.currency = _currency; @@ -433,6 +442,14 @@ class DetailScreenController extends ChangeNotifier { '이벤트활성=${subscription.isEventActive}'); // 구독 업데이트 + // 자동 보정이 발생했으면 안내 + if (adjustedNext.isAfter(originalDateOnly)) { + AppSnackBar.showInfo( + context: context, + message: '다음 결제 예정일로 저장됨', + ); + } + await provider.updateSubscription(subscription); if (context.mounted) { @@ -575,15 +592,5 @@ class DetailScreenController extends ChangeNotifier { return colors[hash % colors.length]; } - /// 그라데이션 가져오기 - LinearGradient getGradient(Color baseColor) { - return LinearGradient( - colors: [ - baseColor, - baseColor.withValues(alpha: 0.8), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - } + // getGradient 제거됨 (그라데이션 미사용) } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8ea4702..2fbec7f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -116,6 +116,7 @@ class AppLocalizations { // 앱 정보 String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get version => _localizedStrings['version'] ?? 'Version'; + String get openStore => _localizedStrings['openStore'] ?? 'Open Store'; String get appDescription => _localizedStrings['appDescription'] ?? 'Subscription Management App'; String get developer => _localizedStrings['developer'] ?? 'Developer'; @@ -367,6 +368,9 @@ class AppLocalizations { String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost'; String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; + String get eventDiscountEndsBeforeBilling => + _localizedStrings['eventDiscountEndsBeforeBilling'] ?? + 'Event discount ends before billing date'; String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress'; String get monthlySavingAmount => diff --git a/lib/main.dart b/lib/main.dart index 0bad487..4c428ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -133,7 +133,9 @@ class SubManagerApp extends StatelessWidget { return MaterialApp( key: ValueKey(localeProvider.locale), - title: 'Digital Rent Manager', + // Localizations는 MaterialApp 내부에서 초기화되므로 + // onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다. + onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, debugShowCheckedModeBanner: false, theme: themeProvider.getTheme(context), locale: localeProvider.locale, diff --git a/lib/screens/add_subscription_screen.dart b/lib/screens/add_subscription_screen.dart index 46ce537..1ca478f 100644 --- a/lib/screens/add_subscription_screen.dart +++ b/lib/screens/add_subscription_screen.dart @@ -5,7 +5,7 @@ import '../widgets/add_subscription/add_subscription_header.dart'; import '../widgets/add_subscription/add_subscription_form.dart'; import '../widgets/add_subscription/add_subscription_event_section.dart'; import '../widgets/add_subscription/add_subscription_save_button.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; /// 새로운 구독을 추가하는 화면 class AddSubscriptionScreen extends StatefulWidget { @@ -45,7 +45,7 @@ class _AddSubscriptionScreenState extends State _controller.scrollController.addListener(_onScroll); return Scaffold( - backgroundColor: AppColors.backgroundColor, + backgroundColor: Theme.of(context).colorScheme.surface, extendBodyBehindAppBar: true, appBar: AddSubscriptionAppBar( controller: _controller, diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart index db8bd89..c3d473e 100644 --- a/lib/screens/app_lock_screen.dart +++ b/lib/screens/app_lock_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_lock_provider.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; class AppLockScreen extends StatelessWidget { const AppLockScreen({super.key}); @@ -13,26 +13,26 @@ class AppLockScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( + Icon( Icons.lock_outline, size: 80, - color: AppColors.navyGray, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(height: 24), - const Text( + Text( '앱이 잠겨 있습니다', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 16), - const Text( + Text( '생체 인증으로 잠금을 해제하세요', style: TextStyle( fontSize: 16, - color: AppColors.navyGray, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 32), @@ -41,15 +41,16 @@ class AppLockScreen extends StatelessWidget { final appLock = context.read(); final success = await appLock.authenticate(); if (!success && context.mounted) { + final cs = Theme.of(context).colorScheme; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( '인증에 실패했습니다. 다시 시도해주세요.', style: TextStyle( - color: AppColors.pureWhite, + color: cs.onPrimary, ), ), - backgroundColor: AppColors.dangerColor, + backgroundColor: cs.error, ), ); } diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index ba4d9b1..be8d27a 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/category_provider.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; import '../l10n/app_localizations.dart'; class CategoryManagementScreen extends StatefulWidget { @@ -43,13 +43,13 @@ class _CategoryManagementScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text( + title: Text( '카테고리 관리', - style: TextStyle( - color: AppColors.pureWhite, - ), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + ), ), - backgroundColor: AppColors.primaryColor, + backgroundColor: Theme.of(context).colorScheme.primary, ), body: Consumer( builder: (context, provider, child) { @@ -66,10 +66,12 @@ class _CategoryManagementScreenState extends State { children: [ TextFormField( controller: _nameController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: '카테고리 이름', labelStyle: TextStyle( - color: AppColors.navyGray, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), validator: (value) { @@ -81,11 +83,13 @@ class _CategoryManagementScreenState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _selectedColor, - decoration: const InputDecoration( + initialValue: _selectedColor, + decoration: InputDecoration( labelText: '색상 선택', labelStyle: TextStyle( - color: AppColors.navyGray, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), items: [ @@ -93,32 +97,42 @@ class _CategoryManagementScreenState extends State { value: '#1976D2', child: Text( AppLocalizations.of(context).colorBlue, - style: const TextStyle( - color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: '#4CAF50', child: Text( AppLocalizations.of(context).colorGreen, - style: const TextStyle( - color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: '#FF9800', child: Text( AppLocalizations.of(context).colorOrange, - style: const TextStyle( - color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: '#F44336', child: Text( AppLocalizations.of(context).colorRed, - style: const TextStyle( - color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: '#9C27B0', child: Text( AppLocalizations.of(context).colorPurple, - style: const TextStyle( - color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), ], onChanged: (value) { setState(() { @@ -128,39 +142,51 @@ class _CategoryManagementScreenState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _selectedIcon, - decoration: const InputDecoration( + initialValue: _selectedIcon, + decoration: InputDecoration( labelText: '아이콘 선택', labelStyle: TextStyle( - color: AppColors.navyGray, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), - items: const [ + items: [ DropdownMenuItem( value: 'subscriptions', child: Text('구독', - style: - TextStyle(color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: 'movie', child: Text('영화', - style: - TextStyle(color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: 'music_note', child: Text('음악', - style: - TextStyle(color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: 'fitness_center', child: Text('운동', - style: - TextStyle(color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), DropdownMenuItem( value: 'shopping_cart', child: Text('쇼핑', - style: - TextStyle(color: AppColors.darkNavy))), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface))), ], onChanged: (value) { setState(() { @@ -171,12 +197,7 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), ElevatedButton( onPressed: _addCategory, - child: const Text( - '카테고리 추가', - style: TextStyle( - color: AppColors.pureWhite, - ), - ), + child: const Text('카테고리 추가'), ), ], ), @@ -201,8 +222,8 @@ class _CategoryManagementScreenState extends State { title: Text( provider.getLocalizedCategoryName( context, category.name), - style: const TextStyle( - color: AppColors.darkNavy, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, ), ), trailing: IconButton( diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 7dab203..71dc565 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -7,7 +7,7 @@ import '../widgets/detail/detail_form_section.dart'; import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_url_section.dart'; import '../widgets/detail/detail_action_buttons.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; import '../l10n/app_localizations.dart'; /// 구독 상세 정보를 표시하고 편집할 수 있는 화면 @@ -50,7 +50,7 @@ class _DetailScreenState extends State return ChangeNotifierProvider.value( value: _controller, child: Scaffold( - backgroundColor: AppColors.backgroundColor, + backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView( controller: _controller.scrollController, slivers: [ @@ -77,17 +77,16 @@ class _DetailScreenState extends State vertical: 12, ), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - baseColor.withValues(alpha: 0.15), - baseColor.withValues(alpha: 0.08), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.4), borderRadius: BorderRadius.circular(16), border: Border.all( - color: baseColor.withValues(alpha: 0.2), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3), width: 1, ), ), @@ -111,9 +110,9 @@ class _DetailScreenState extends State Text( AppLocalizations.of(context) .changesAppliedAfterSave, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), ], diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 6d87d46..1a2caf1 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -3,7 +3,8 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../providers/app_lock_provider.dart'; import '../providers/navigation_provider.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; +import '../theme/color_scheme_ext.dart'; import '../routes/app_routes.dart'; import 'analysis_screen.dart'; import 'app_lock_screen.dart'; @@ -11,7 +12,6 @@ import 'settings_screen.dart'; import 'sms_scan_screen.dart'; import '../utils/animation_controller_helper.dart'; import '../widgets/floating_navigation_bar.dart'; -import '../widgets/glassmorphic_scaffold.dart'; import '../widgets/home_content.dart'; import '../l10n/app_localizations.dart'; import '../utils/platform_helper.dart'; @@ -162,33 +162,34 @@ class _MainScreenState extends State if (result == true) { // 상단에 스낵바 표시 if (!context.mounted) return; + final cs = Theme.of(context).colorScheme; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ - const Icon( + Icon( Icons.check_circle, - color: AppColors.pureWhite, + color: cs.onPrimary, size: 20, ), const SizedBox(width: 12), Text( AppLocalizations.of(context).subscriptionAdded, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: AppColors.pureWhite, + color: cs.onPrimary, ), ), ], ), - backgroundColor: AppColors.successColor, + backgroundColor: cs.success, behavior: SnackBarBehavior.floating, margin: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 8, // 더 상단으로 + top: MediaQuery.of(context).padding.top + 8, left: 16, right: 16, - bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로 + bottom: MediaQuery.of(context).size.height - 100, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -223,8 +224,7 @@ class _MainScreenState extends State Widget build(BuildContext context) { final navigationProvider = context.watch(); - // 메인 그라데이션 사용 - List backgroundGradient = AppColors.mainGradient; + // 그라데이션 제거: 단색 배경 사용 // 현재 인덱스가 유효한지 확인 int currentIndex = navigationProvider.currentIndex; @@ -232,25 +232,31 @@ class _MainScreenState extends State currentIndex = 0; // 추가 버튼은 홈으로 표시 } - return GlassmorphicScaffold( - body: IndexedStack( - index: PlatformHelper.isIOS - ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 - : (currentIndex == 3 - ? 3 - : currentIndex == 4 - ? 4 - : currentIndex), // Android: 기존 로직 - children: _screens, - ), - backgroundGradient: backgroundGradient, - useFloatingNavBar: true, - floatingNavBarIndex: navigationProvider.currentIndex, - onFloatingNavBarTapped: (index) { - _handleNavigation(index, context); - }, - enableParticles: false, - enableWaveAnimation: false, + return Stack( + children: [ + Positioned.fill( + child: Container(color: Theme.of(context).colorScheme.surface), + ), + Scaffold( + extendBody: true, + extendBodyBehindAppBar: true, + body: IndexedStack( + index: PlatformHelper.isIOS + ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 + : (currentIndex == 3 + ? 3 + : currentIndex == 4 + ? 4 + : currentIndex), // Android: 기존 로직 + children: _screens, + ), + ), + FloatingNavigationBar( + selectedIndex: navigationProvider.currentIndex, + isVisible: true, + onItemTapped: (index) => _handleNavigation(index, context), + ), + ], ); } } diff --git a/lib/screens/sms_permission_screen.dart b/lib/screens/sms_permission_screen.dart index 01486e4..1f440da 100644 --- a/lib/screens/sms_permission_screen.dart +++ b/lib/screens/sms_permission_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart' as permission; -import '../theme/app_colors.dart'; -import '../widgets/glassmorphism_card.dart'; +// Material colors only +// Glass 제거: Material 3 Card 사용 import '../routes/app_routes.dart'; import '../l10n/app_localizations.dart'; import '../services/sms_service.dart'; @@ -92,12 +92,13 @@ class _SmsPermissionScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.sms, size: 64, color: AppColors.primaryColor), + Icon(Icons.sms, + size: 64, color: Theme.of(context).colorScheme.primary), const SizedBox(height: 16), Text( loc.smsPermissionTitle, style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: AppColors.textPrimary, + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, ), ), @@ -105,24 +106,39 @@ class _SmsPermissionScreenState extends State { Text( loc.smsPermissionRequired, textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textSecondary), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: 16), - GlassmorphismCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(loc.smsPermissionReasonTitle, - style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text(loc.smsPermissionReasonBody), - const SizedBox(height: 12), - Text(loc.smsPermissionScopeTitle, - style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text(loc.smsPermissionScopeBody), - ], + Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(loc.smsPermissionReasonTitle, + style: + const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(loc.smsPermissionReasonBody), + const SizedBox(height: 12), + Text(loc.smsPermissionScopeTitle, + style: + const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(loc.smsPermissionScopeBody), + ], + ), ), ), const SizedBox(height: 24), diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index fb752e1..ca1ff9c 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; import '../services/sms_service.dart'; import '../utils/platform_helper.dart'; import '../routes/app_routes.dart'; @@ -90,8 +90,6 @@ class _SplashScreenState extends State (reduced ? 1200 : 2000); // 축소 시 더 짧게 final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간 - int colorIndex = (random + i) % AppColors.blueGradient.length; - _particles.add({ 'size': size, 'x': x, @@ -99,7 +97,7 @@ class _SplashScreenState extends State 'opacity': opacity, 'duration': duration, 'delay': delay, - 'color': AppColors.blueGradient[colorIndex], + // color computed at render from ColorScheme.primary }); } } @@ -137,23 +135,15 @@ class _SplashScreenState extends State return Scaffold( body: Stack( children: [ - // 배경 그라디언트 - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - AppColors.dayGradient[0], - AppColors.dayGradient[1], - ], - ), - ), - ), + // 단색 배경 + Container(color: Theme.of(context).colorScheme.surface), // 글래스모피즘 오버레이 Container( decoration: BoxDecoration( - color: AppColors.pureWhite.withValues(alpha: 0.05), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.05), ), ), Stack( @@ -180,11 +170,14 @@ class _SplashScreenState extends State width: particle['size'], height: particle['size'], decoration: BoxDecoration( - color: particle['color'], + color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle, boxShadow: [ BoxShadow( - color: particle['color'].withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), blurRadius: 10, spreadRadius: 1, ), @@ -195,43 +188,23 @@ class _SplashScreenState extends State ); }).toList(), - // 상단 원형 그라데이션 + // 상단 원형 장식 제거(단색 배경 유지) Positioned( top: -size.height * 0.2, right: -size.width * 0.2, - child: Container( + child: SizedBox( width: size.width * 0.8, height: size.width * 0.8, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - AppColors.pureWhite.withValues(alpha: 0.1), - AppColors.pureWhite.withValues(alpha: 0.0), - ], - stops: const [0.2, 1.0], - ), - ), ), ), - // 하단 원형 그라데이션 + // 하단 원형 장식 제거 Positioned( bottom: -size.height * 0.1, left: -size.width * 0.3, - child: Container( + child: SizedBox( width: size.width * 0.9, height: size.width * 0.9, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - AppColors.pureWhite.withValues(alpha: 0.07), - AppColors.pureWhite.withValues(alpha: 0.0), - ], - stops: const [0.4, 1.0], - ), - ), ), ), @@ -271,62 +244,32 @@ class _SplashScreenState extends State reduced: 8)), child: Container( decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.pureWhite - .withValues(alpha: 0.2), - AppColors.pureWhite - .withValues(alpha: 0.1), - ], - ), + color: Theme.of(context) + .colorScheme + .surface + .withValues(alpha: 0.6), borderRadius: BorderRadius.circular(30), border: Border.all( - color: AppColors.pureWhite - .withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2), width: 1.5, ), - boxShadow: [ - BoxShadow( - color: - AppColors.shadowBlack, - spreadRadius: 0, - blurRadius: - ReduceMotion.scale( - context, - normal: 30, - reduced: 12), - offset: const Offset(0, 10), - ), - ], ), child: Center( child: AnimatedBuilder( animation: _animationController, builder: (context, _) { - return ShaderMask( - blendMode: - BlendMode.srcIn, - shaderCallback: (bounds) => - const LinearGradient( - colors: AppColors - .blueGradient, - begin: - Alignment.topLeft, - end: Alignment - .bottomRight, - ).createShader(bounds), - child: Icon( - Icons - .subscriptions_outlined, - size: 64, - color: - Theme.of(context) - .primaryColor, - ), + return Icon( + Icons + .subscriptions_outlined, + size: 64, + color: Theme.of(context) + .colorScheme + .primary, ); }), ), @@ -356,7 +299,9 @@ class _SplashScreenState extends State style: TextStyle( fontSize: 36, fontWeight: FontWeight.bold, - color: AppColors.primaryColor + color: Theme.of(context) + .colorScheme + .primary .withValues(alpha: 0.9), letterSpacing: 1.2, ), @@ -382,7 +327,9 @@ class _SplashScreenState extends State AppLocalizations.of(context).appSubtitle, style: TextStyle( fontSize: 16, - color: AppColors.primaryColor + color: Theme.of(context) + .colorScheme + .primary .withValues(alpha: 0.7), letterSpacing: 0.5, ), @@ -404,18 +351,22 @@ class _SplashScreenState extends State height: 60, padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: AppColors.pureWhite + color: Theme.of(context) + .colorScheme + .onSurface .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(50), border: Border.all( - color: AppColors.pureWhite + color: Theme.of(context) + .colorScheme + .onSurface .withValues(alpha: 0.2), width: 1, ), ), - child: const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - AppColors.pureWhite), + child: CircularProgressIndicator( + color: + Theme.of(context).colorScheme.primary, strokeWidth: 3, ), ), @@ -436,7 +387,10 @@ class _SplashScreenState extends State '© 2025 NatureBridgeAI. All rights reserved.', style: TextStyle( fontSize: 12, - color: AppColors.pureWhite.withValues(alpha: 0.6), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), letterSpacing: 0.5, ), ), diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 1af7413..4db708d 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -5,6 +5,7 @@ import '../utils/logger.dart'; import '../temp/test_sms_data.dart'; import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; +import '../utils/business_day_util.dart'; class SmsScanner { final SmsQuery _query = SmsQuery(); @@ -56,7 +57,61 @@ class SmsScanner { // 2회 이상 반복된 서비스만 구독으로 간주 if (entry.value.length >= 2) { - final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 + // 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용 + final messages = [...entry.value]; + messages.sort((a, b) { + final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ?? + DateTime(1970); + final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ?? + DateTime(1970); + return db.compareTo(da); // desc + }); + + final mostRecent = messages.first; + DateTime? recentDate = + DateTime.tryParse(mostRecent['previousPaymentDate'] ?? ''); + DateTime? prevDate = messages.length > 1 + ? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '') + : null; + + // 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자 + int baseDay = recentDate?.day ?? DateTime.now().day; + + // 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정 + if (recentDate != null && prevDate != null) { + final candidate = DateTime(prevDate.year, prevDate.month, baseDay); + if (BusinessDayUtil.isWeekend(candidate)) { + final diff = prevDate.difference(candidate).inDays; + if (diff >= 1 && diff <= 3) { + // 예: 12일(토)→14일(월) + baseDay = baseDay; // 유지 + } else { + // 차이가 크면 이전 달의 일자를 채택 + baseDay = prevDate.day; + } + } + } + + // 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정 + final DateTime now = DateTime.now(); + int year = now.year; + int month = now.month; + if (now.day >= baseDay) { + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + final dim = BusinessDayUtil.daysInMonth(year, month); + final day = baseDay.clamp(1, dim); + DateTime nextBilling = DateTime(year, month, day); + nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling); + + // 가장 최근 SMS 맵에 override 값으로 주입 + final serviceSms = Map.from(mostRecent); + serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String(); + final subscription = _parseSms(serviceSms, entry.value.length); if (subscription != null) { Log.i( @@ -134,7 +189,11 @@ class SmsScanner { } DateTime? nextBillingDate; - if (nextBillingDateStr != null) { + // 외부에서 계산된 다음 결제일이 있으면 우선 사용 + final overrideNext = sms['overrideNextBillingDate'] as String?; + if (overrideNext != null) { + nextBillingDate = DateTime.tryParse(overrideNext); + } else if (nextBillingDateStr != null) { nextBillingDate = DateTime.tryParse(nextBillingDateStr); } @@ -146,8 +205,12 @@ class SmsScanner { // 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정 DateTime adjustedNextBillingDate = _calculateNextBillingDate( - nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), - billingCycle); + nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), + billingCycle, + ); + // 주말/공휴일 보정 + adjustedNextBillingDate = + BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate); return SubscriptionModel( id: DateTime.now().millisecondsSinceEpoch.toString(), @@ -190,7 +253,9 @@ class SmsScanner { } } - return DateTime(year, month, billingDate.day); + final dim = BusinessDayUtil.daysInMonth(year, month); + final day = billingDate.day.clamp(1, dim); + return DateTime(year, month, day); } else if (billingCycle == 'yearly') { // 올해의 결제일이 지났는지 확인 final thisYearBilling = diff --git a/lib/theme/adaptive_theme.dart b/lib/theme/adaptive_theme.dart index a705569..798b229 100644 --- a/lib/theme/adaptive_theme.dart +++ b/lib/theme/adaptive_theme.dart @@ -10,114 +10,119 @@ class AdaptiveTheme { /// 다크 테마 static ThemeData get darkTheme { + const scheme = ColorScheme.dark( + primary: AppColors.primaryColor, + onPrimary: Colors.white, + secondary: AppColors.secondaryColor, + tertiary: AppColors.infoColor, + error: AppColors.errorColor, + surface: Color(0xFF1E1E1E), + ); + return ThemeData( useMaterial3: true, brightness: Brightness.dark, - colorScheme: const ColorScheme.dark( - primary: AppColors.primaryColor, - onPrimary: Colors.white, - secondary: AppColors.secondaryColor, - tertiary: AppColors.infoColor, - error: AppColors.dangerColor, - surface: Color(0xFF1E1E1E), - ), + colorScheme: scheme, scaffoldBackgroundColor: const Color(0xFF121212), cardTheme: CardThemeData( - color: const Color(0xFF1E1E1E), - elevation: 2, - shadowColor: Colors.black.withValues(alpha: 0.3), + color: scheme.surface, + elevation: 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( - color: Colors.white.withValues(alpha: 0.1), width: 0.5), + color: const Color(0xFFFFFFFF).withValues(alpha: 0.08), + width: 1, + ), ), clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), ), appBarTheme: AppBarTheme( - backgroundColor: const Color(0xFF1E1E1E), - foregroundColor: Colors.white, + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, elevation: 0, centerTitle: false, - titleTextStyle: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - ), - iconTheme: IconThemeData( - color: Colors.white.withValues(alpha: 0.9), - size: 24, - ), - ), - textTheme: TextTheme( - headlineLarge: const TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - height: 1.2, - ), - headlineMedium: const TextStyle( - color: Colors.white, - fontSize: 28, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - height: 1.2, - ), - headlineSmall: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.w600, - letterSpacing: -0.25, - height: 1.3, - ), - titleLarge: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - height: 1.4, - ), - titleMedium: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - letterSpacing: -0.1, - height: 1.4, - ), - titleSmall: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0, - height: 1.4, - ), - bodyLarge: TextStyle( - color: Colors.white.withValues(alpha: 0.9), - fontSize: 16, - fontWeight: FontWeight.w400, - letterSpacing: 0.1, - height: 1.5, - ), - bodyMedium: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 14, - fontWeight: FontWeight.w400, - letterSpacing: 0.1, - height: 1.5, - ), - bodySmall: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 12, - fontWeight: FontWeight.w400, - letterSpacing: 0.2, - height: 1.5, - ), + // title/icon colors inherit from foregroundColor ), + textTheme: ThemeData.dark(useMaterial3: true) + .textTheme + .copyWith( + headlineLarge: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ), + headlineMedium: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ), + headlineSmall: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: -0.25, + height: 1.3, + ), + titleLarge: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + height: 1.4, + ), + titleMedium: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + height: 1.4, + ), + titleSmall: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.4, + ), + bodyLarge: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.5, + ), + bodyMedium: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.5, + ), + bodySmall: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.2, + height: 1.5, + ), + labelLarge: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + height: 1.4, + ), + labelMedium: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + height: 1.4, + ), + labelSmall: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + height: 1.4, + ), + ) + .apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: const Color(0xFF2A2A2A), + fillColor: scheme.surface, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( @@ -126,33 +131,31 @@ class AdaptiveTheme { ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: - BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1), + borderSide: BorderSide(color: scheme.outline, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: - const BorderSide(color: AppColors.primaryColor, width: 1.5), + borderSide: BorderSide(color: scheme.primary, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), + borderSide: BorderSide(color: scheme.error, width: 1), ), labelStyle: TextStyle( - color: Colors.white.withValues(alpha: 0.7), + color: scheme.onSurfaceVariant, fontSize: 14, fontWeight: FontWeight.w500, ), hintStyle: TextStyle( - color: Colors.white.withValues(alpha: 0.5), + color: scheme.onSurfaceVariant, fontSize: 14, fontWeight: FontWeight.w400, ), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, minimumSize: const Size(0, 48), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), shape: RoundedRectangleBorder( @@ -161,8 +164,66 @@ class AdaptiveTheme { elevation: 0, ), ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return scheme.onSurfaceVariant; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary.withValues(alpha: 0.5); + } + return scheme.surfaceContainerHighest.withValues(alpha: 0.5); + }), + ), + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return Colors.transparent; + }), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + side: BorderSide(color: scheme.outline, width: 1.5), + ), + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return scheme.onSurfaceVariant; + }), + ), + sliderTheme: SliderThemeData( + activeTrackColor: scheme.primary, + inactiveTrackColor: scheme.onSurfaceVariant, + thumbColor: scheme.primary, + overlayColor: scheme.primary.withValues(alpha: 0.5), + trackHeight: 4, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 20), + ), + tabBarTheme: TabBarThemeData( + labelColor: scheme.primary, + unselectedLabelColor: scheme.onSurfaceVariant, + indicatorColor: scheme.primary, + labelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + ), dividerTheme: DividerThemeData( - color: Colors.white.withValues(alpha: 0.1), + color: scheme.outline, thickness: 1, space: 16, ), @@ -171,19 +232,15 @@ class AdaptiveTheme { /// OLED 최적화 다크 테마 static ThemeData get oledTheme { - return darkTheme.copyWith( + final base = darkTheme; + const oledSurface = Color(0xFF0A0A0A); + return base.copyWith( scaffoldBackgroundColor: Colors.black, - colorScheme: darkTheme.colorScheme.copyWith( - surface: const Color(0xFF0A0A0A), - ), - cardTheme: darkTheme.cardTheme.copyWith( - color: const Color(0xFF0A0A0A), - ), - appBarTheme: darkTheme.appBarTheme.copyWith( - backgroundColor: Colors.black, - ), - inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith( - fillColor: const Color(0xFF0A0A0A), + colorScheme: base.colorScheme.copyWith(surface: oledSurface), + cardTheme: base.cardTheme.copyWith(color: oledSurface), + appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black), + inputDecorationTheme: base.inputDecorationTheme.copyWith( + fillColor: oledSurface, ), ); } diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart index 0c10bdc..1bcdc52 100644 --- a/lib/theme/app_colors.dart +++ b/lib/theme/app_colors.dart @@ -7,7 +7,8 @@ class AppColors { static const successColor = Color(0xFF38BDF8); // 소프트 민트 static const infoColor = Color(0xFF6366F1); // 인디고 static const warningColor = Color(0xFFF59E0B); // 앰버 - static const dangerColor = Color(0xFFF472B6); // 핑크 액센트 + static const dangerColor = Color(0xFFF472B6); // 핑크 액센트 (액센트 용도) + static const errorColor = Color(0xFFEF4444); // 레드 (오류 용도) // 배경색 static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100 @@ -31,27 +32,7 @@ class AppColors { // 그림자 (color.md 가이드) static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity - // 그라데이션 컬러 - 다양한 효과를 위한 조합 - static const List blueGradient = [ - Color(0xFF2563EB), // 딥 블루 - Color(0xFF60A5FA) // 스카이 블루 - ]; - static const List tealGradient = [ - Color(0xFF14B8A6), - Color(0xFF0D9488) - ]; - static const List purpleGradient = [ - Color(0xFF8B5CF6), - Color(0xFF7C3AED) - ]; - static const List amberGradient = [ - Color(0xFFF59E0B), - Color(0xFFD97706) - ]; - static const List roseGradient = [ - Color(0xFFF43F5E), - Color(0xFFE11D48) - ]; + // (그라데이션 컬러 제거됨) // Glassmorphism 효과를 위한 색상 static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity) @@ -66,47 +47,9 @@ class AppColors { static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity) static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity) - // 백드롭 블러 효과를 위한 그라디언트 - static const List glassGradient = [ - Color(0x33FFFFFF), // 20% white - Color(0x1AFFFFFF), // 10% white - ]; + // (백드롭 블러 그라데이션 제거됨) - static const List glassGradientDark = [ - Color(0x1A000000), // 10% black - Color(0x0F000000), // 6% black - ]; + // (메인/액센트 그라데이션 제거됨) - // 메인 그라데이션 - static const List mainGradient = [ - Color(0xFF2563EB), // 딥 블루 - Color(0xFF60A5FA), // 스카이 블루 - Color(0xFFE0E7EF), // 라이트 그레이 - ]; - - static const List accentGradient = [ - Color(0xFF38BDF8), // 소프트 민트 - Color(0xFF60A5FA), // 스카이 블루 - ]; - - // 시간대별 배경 그라디언트 - static const List morningGradient = [ - Color(0xFFFED7AA), // 따뜻한 오렌지 - Color(0xFFFBBF24), // 부드러운 노랑 - ]; - - static const List dayGradient = [ - Color(0xFFDDEAFC), // 연한 하늘색 - Color(0xFFBFDBFE), // 맑은 파랑 - ]; - - static const List eveningGradient = [ - Color(0xFFFCA5A5), // 부드러운 핑크 - Color(0xFFC084FC), // 연한 보라 - ]; - - static const List nightGradient = [ - Color(0xFF4338CA), // 깊은 인디고 - Color(0xFF1E1B4B), // 다크 네이비 - ]; + // (시간대별 배경 그라데이션 제거됨) } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index df42009..a911db1 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -2,354 +2,320 @@ import 'package:flutter/material.dart'; import 'app_colors.dart'; class AppTheme { - static ThemeData lightTheme = ThemeData( - useMaterial3: true, - colorScheme: const ColorScheme.light( + static ThemeData lightTheme = (() { + // Color scheme for light theme + const scheme = ColorScheme.light( primary: AppColors.primaryColor, onPrimary: Colors.white, secondary: AppColors.secondaryColor, tertiary: AppColors.infoColor, - error: AppColors.dangerColor, + error: AppColors.errorColor, surface: AppColors.surfaceColor, - ), + ); - // 기본 배경색 - scaffoldBackgroundColor: AppColors.backgroundColor, + return ThemeData( + useMaterial3: true, + colorScheme: scheme, - // 카드 스타일 - 글래스모피즘 효과 - cardTheme: CardThemeData( - color: AppColors.glassCard, - elevation: 0, - shadowColor: AppColors.shadowBlack, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: AppColors.glassBorder, width: 1), - ), - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - ), + // 기본 배경색 + scaffoldBackgroundColor: AppColors.backgroundColor, - // 앱바 스타일 - 글래스모피즘 디자인 - appBarTheme: const AppBarTheme( - backgroundColor: Colors.transparent, - foregroundColor: AppColors.textPrimary, - elevation: 0, - centerTitle: false, - titleTextStyle: TextStyle( - color: AppColors.textPrimary, - fontSize: 22, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - ), - iconTheme: IconThemeData( - color: AppColors.primaryColor, - size: 24, - ), - ), - - // 타이포그래피 - Metronic Tailwind 스타일 - textTheme: const TextTheme( - // 헤드라인 - 페이지 제목 - headlineLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 32, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - height: 1.2, - ), - headlineMedium: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 28, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - height: 1.2, - ), - headlineSmall: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 24, - fontWeight: FontWeight.w600, - letterSpacing: -0.25, - height: 1.3, - ), - - // 타이틀 - 카드, 섹션 제목 - titleLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 20, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - height: 1.4, - ), - titleMedium: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 18, - fontWeight: FontWeight.w600, - letterSpacing: -0.1, - height: 1.4, - ), - titleSmall: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0, - height: 1.4, - ), - - // 본문 텍스트 - bodyLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 16, - fontWeight: FontWeight.w400, - letterSpacing: 0.1, - height: 1.5, - ), - bodyMedium: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - fontSize: 14, - fontWeight: FontWeight.w400, - letterSpacing: 0.1, - height: 1.5, - ), - bodySmall: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - fontSize: 12, - fontWeight: FontWeight.w400, - letterSpacing: 0.2, - height: 1.5, - ), - - // 라벨 텍스트 - labelLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - fontSize: 14, - fontWeight: FontWeight.w600, - letterSpacing: 0.1, - height: 1.4, - ), - labelMedium: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - fontSize: 12, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - height: 1.4, - ), - labelSmall: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - fontSize: 11, - fontWeight: FontWeight.w500, - letterSpacing: 0.2, - height: 1.4, - ), - ), - - // 입력 필드 스타일 - 글래스모피즘 디자인 - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: AppColors.glassBackground, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.textSecondary, width: 1), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5), - ), - labelStyle: const TextStyle( - color: AppColors.textSecondary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - hintStyle: const TextStyle( - color: AppColors.textMuted, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - errorStyle: const TextStyle( - color: AppColors.dangerColor, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - - // 버튼 스타일 - 프라이머리 버튼 - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, - minimumSize: const Size(0, 48), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + // 카드 스타일 - Material 3 표면 중심 + cardTheme: CardThemeData( + elevation: 1, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + ), + + // 앱바 스타일 - 기본 M3 사용(투명 배경 유지) + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, elevation: 0, - textStyle: const TextStyle( + centerTitle: false, + ), + + // 타이포그래피 - Material 3 + onSurface 정렬 + textTheme: ThemeData.light(useMaterial3: true) + .textTheme + .copyWith( + headlineLarge: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ), + headlineMedium: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ), + headlineSmall: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: -0.25, + height: 1.3, + ), + titleLarge: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + height: 1.4, + ), + titleMedium: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + height: 1.4, + ), + titleSmall: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.4, + ), + bodyLarge: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.5, + ), + bodyMedium: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.5, + ), + bodySmall: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.2, + height: 1.5, + ), + labelLarge: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + height: 1.4, + ), + labelMedium: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + height: 1.4, + ), + labelSmall: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + height: 1.4, + ), + ) + .apply( + // 본문/헤드라인 공통 색상은 onSurface로 적용 + bodyColor: scheme.onSurface, + displayColor: scheme.onSurface, + ), + + // 입력 필드 스타일 - M3 surface/outline 기반 + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surface, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: scheme.outline, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: scheme.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: scheme.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: scheme.error, width: 1.5), + ), + labelStyle: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + hintStyle: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + errorStyle: TextStyle( + color: scheme.error, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + + // 버튼 스타일 - 프라이머리 버튼 + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + minimumSize: const Size(0, 48), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + ), + + // 텍스트 버튼 스타일 + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: scheme.primary, + minimumSize: const Size(0, 40), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + // Text style inherits from theme.labelLarge + ), + ), + + // 아웃라인 버튼 스타일 + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: scheme.primary, + minimumSize: const Size(0, 48), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: scheme.outline, width: 1), + ), + ), + + // FAB 스타일 + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 2, + extendedPadding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + extendedTextStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, letterSpacing: 0.1, ), ), - ), - // 텍스트 버튼 스타일 - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: AppColors.primaryColor, - minimumSize: const Size(0, 40), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + // 스위치 스타일 (공통 테마) + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return scheme.onSurfaceVariant; // OFF 썸을 명확하게 + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary.withValues(alpha: 0.5); + } + // OFF 트랙 대비 강화 + return scheme.surfaceContainerHighest.withValues(alpha: 0.5); + }), + ), + + // 체크박스 스타일 + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return Colors.transparent; + }), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(4), ), - textStyle: const TextStyle( + side: BorderSide(color: scheme.outline, width: 1.5), + ), + + // 라디오 버튼 스타일 + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return scheme.primary; + } + return scheme.onSurfaceVariant; + }), + ), + + // 슬라이더 스타일 + sliderTheme: SliderThemeData( + activeTrackColor: scheme.primary, + inactiveTrackColor: scheme.onSurfaceVariant, + thumbColor: scheme.primary, + overlayColor: scheme.primary.withValues(alpha: 0.3), + trackHeight: 4, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 20), + ), + + // 탭바 스타일 + tabBarTheme: TabBarThemeData( + labelColor: scheme.primary, + unselectedLabelColor: scheme.onSurfaceVariant, + indicatorColor: scheme.primary, + labelStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.1, ), - ), - ), - - // 아웃라인 버튼 스타일 - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primaryColor, - minimumSize: const Size(0, 48), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - side: const BorderSide(color: AppColors.secondaryColor, width: 1), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + unselectedLabelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, letterSpacing: 0.1, ), ), - ), - // FAB 스타일 - floatingActionButtonTheme: FloatingActionButtonThemeData( - backgroundColor: AppColors.primaryColor, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + // 디바이더 스타일 + dividerTheme: DividerThemeData( + color: scheme.outline, + thickness: 1, + space: 16, ), - elevation: 2, - extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - extendedTextStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - letterSpacing: 0.1, + + // 페이지 트랜지션 + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + }, ), - ), - // 스위치 스타일 - switchTheme: SwitchThemeData( - thumbColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.primaryColor; - } - return Colors.white; - }), - trackColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.secondaryColor.withValues(alpha: 0.5); - } - return AppColors.borderColor; - }), - ), - - // 체크박스 스타일 - checkboxTheme: CheckboxThemeData( - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.primaryColor; - } - return Colors.transparent; - }), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + // 스낵바 스타일 (기본 유지) + snackBarTheme: SnackBarThemeData( + backgroundColor: scheme.primary, + contentTextStyle: TextStyle( + color: scheme.onPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + behavior: SnackBarBehavior.floating, ), - side: const BorderSide(color: AppColors.secondaryColor, width: 1.5), - ), - - // 라디오 버튼 스타일 - radioTheme: RadioThemeData( - fillColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return AppColors.primaryColor; - } - return AppColors.textSecondary; - }), - ), - - // 슬라이더 스타일 - sliderTheme: SliderThemeData( - activeTrackColor: AppColors.primaryColor, - inactiveTrackColor: AppColors.textSecondary, - thumbColor: AppColors.primaryColor, - overlayColor: AppColors.primaryColor.withValues(alpha: 0.3), - trackHeight: 4, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 20), - ), - - // 탭바 스타일 - tabBarTheme: const TabBarThemeData( - labelColor: AppColors.primaryColor, - unselectedLabelColor: AppColors.textSecondary, - indicatorColor: AppColors.primaryColor, - labelStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - letterSpacing: 0.1, - ), - unselectedLabelStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - letterSpacing: 0.1, - ), - ), - - // 디바이더 스타일 - dividerTheme: const DividerThemeData( - color: AppColors.dividerColor, - thickness: 1, - space: 16, - ), - - // 페이지 트랜지션 - pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: ZoomPageTransitionsBuilder(), - TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), - TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), - }, - ), - - // 스낵바 스타일 - snackBarTheme: SnackBarThemeData( - backgroundColor: AppColors.textPrimary, - contentTextStyle: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - behavior: SnackBarBehavior.floating, - ), - ); + ); + })(); } diff --git a/lib/theme/color_scheme_ext.dart b/lib/theme/color_scheme_ext.dart new file mode 100644 index 0000000..3b0a0a3 --- /dev/null +++ b/lib/theme/color_scheme_ext.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +extension AppColorRoles on ColorScheme { + // Semantic roles not present in ColorScheme by default + Color get success => const Color(0xFF22C55E); // green 600 + Color get warning => const Color(0xFFF59E0B); // amber 600 + Color get info => tertiary; // map info to tertiary +} diff --git a/lib/theme/ui_constants.dart b/lib/theme/ui_constants.dart new file mode 100644 index 0000000..58524b5 --- /dev/null +++ b/lib/theme/ui_constants.dart @@ -0,0 +1,7 @@ +class UIConstants { + static const double pageHorizontalPadding = 16; + static const double adVerticalPadding = 12; + static const double adCardHeight = 88; + static const double cardRadius = 16; + static const double cardOutlineAlpha = 0.5; // for outline color alpha +} diff --git a/lib/utils/animation_controller_helper.dart b/lib/utils/animation_controller_helper.dart index 13c85fa..6052a6f 100644 --- a/lib/utils/animation_controller_helper.dart +++ b/lib/utils/animation_controller_helper.dart @@ -32,17 +32,9 @@ class AnimationControllerHelper { pulseController.duration = const Duration(milliseconds: 1500); pulseController.repeat(reverse: true); - // 웨이브 컨트롤러 초기화 + // 웨이브 컨트롤러 초기화: 반복으로 부드럽게 루프 waveController.duration = const Duration(milliseconds: 8000); - waveController.forward(); - - // 웨이브 애니메이션이 끝나면 다시 처음부터 부드럽게 시작하도록 설정 - waveController.addStatusListener((status) { - if (status == AnimationStatus.completed) { - waveController.reset(); - waveController.forward(); - } - }); + waveController.repeat(); } /// 모든 애니메이션 컨트롤러를 재설정하는 메서드 diff --git a/lib/utils/billing_date_util.dart b/lib/utils/billing_date_util.dart new file mode 100644 index 0000000..fa4df28 --- /dev/null +++ b/lib/utils/billing_date_util.dart @@ -0,0 +1,103 @@ +import 'business_day_util.dart'; + +/// 결제 주기 및 결제일 계산 유틸리티 +class BillingDateUtil { + /// 결제 주기를 표준 키로 정규화합니다. + /// 반환값 예: 'monthly' | 'quarterly' | 'half-yearly' | 'yearly' | 'weekly' + static String normalizeCycle(String cycle) { + final c = cycle.trim().toLowerCase(); + + // 영어 우선 매핑 + if (c.contains('monthly')) return 'monthly'; + if (c.contains('quarter')) return 'quarterly'; + if (c.contains('half') || c.contains('half-year')) return 'half-yearly'; + if (c.contains('year')) return 'yearly'; + if (c.contains('week')) return 'weekly'; + + // 한국어 + if (cycle.contains('매월') || cycle.contains('월간')) return 'monthly'; + if (cycle.contains('분기')) return 'quarterly'; + if (cycle.contains('반기')) return 'half-yearly'; + if (cycle.contains('매년') || cycle.contains('연간')) return 'yearly'; + if (cycle.contains('주간')) return 'weekly'; + + // 일본어 + if (cycle.contains('毎月')) return 'monthly'; + if (cycle.contains('四半期')) return 'quarterly'; + if (cycle.contains('半年')) return 'half-yearly'; + if (cycle.contains('年間')) return 'yearly'; + if (cycle.contains('週間')) return 'weekly'; + + // 중국어(간체/번체 공통 표현 대응) + if (cycle.contains('每月')) return 'monthly'; + if (cycle.contains('每季度')) return 'quarterly'; + if (cycle.contains('每半年')) return 'half-yearly'; + if (cycle.contains('每年')) return 'yearly'; + if (cycle.contains('每周') || cycle.contains('每週')) return 'weekly'; + + // 기본값 + return 'monthly'; + } + + /// 선택된 날짜가 오늘(또는 과거)이면, 결제 주기에 맞춰 다음 회차 날짜로 보정합니다. + /// 이미 미래라면 해당 날짜를 그대로 반환합니다. + static DateTime ensureFutureDate(DateTime selected, String cycle) { + final normalized = normalizeCycle(cycle); + final selectedDateOnly = + DateTime(selected.year, selected.month, selected.day); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + if (selectedDateOnly.isAfter(today)) return selectedDateOnly; + + DateTime next = selectedDateOnly; + switch (normalized) { + case 'weekly': + while (!next.isAfter(today)) { + next = next.add(const Duration(days: 7)); + } + break; + case 'quarterly': + while (!next.isAfter(today)) { + next = _addMonthsClamped(next, 3); + } + break; + case 'half-yearly': + while (!next.isAfter(today)) { + next = _addMonthsClamped(next, 6); + } + break; + case 'yearly': + while (!next.isAfter(today)) { + next = _addYearsClamped(next, 1); + } + break; + case 'monthly': + default: + while (!next.isAfter(today)) { + next = _addMonthsClamped(next, 1); + } + break; + } + + return next; + } + + /// month 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다. + static DateTime _addMonthsClamped(DateTime base, int months) { + final totalMonths = base.month - 1 + months; + final year = base.year + totalMonths ~/ 12; + final month = totalMonths % 12 + 1; + final dim = BusinessDayUtil.daysInMonth(year, month); + final day = base.day.clamp(1, dim); + return DateTime(year, month, day); + } + + /// year 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다. + static DateTime _addYearsClamped(DateTime base, int years) { + final year = base.year + years; + final dim = BusinessDayUtil.daysInMonth(year, base.month); + final day = base.day.clamp(1, dim); + return DateTime(year, base.month, day); + } +} diff --git a/lib/utils/business_day_util.dart b/lib/utils/business_day_util.dart new file mode 100644 index 0000000..7d1fb5d --- /dev/null +++ b/lib/utils/business_day_util.dart @@ -0,0 +1,39 @@ +/// 영업일 계산 유틸리티 +/// - 주말(토/일)과 일부 고정 공휴일을 제외하고 다음 영업일을 계산합니다. +/// - 음력 기반 공휴일(설/추석 등)은 포함하지 않습니다. 필요 시 외부 소스 연동을 고려하세요. +class BusinessDayUtil { + static bool isWeekend(DateTime date) => + date.weekday == DateTime.saturday || date.weekday == DateTime.sunday; + + /// 고정일 한국 공휴일(대체공휴일 미포함) + static const List _fixedHolidays = [ + '01-01', // 신정 + '03-01', // 삼일절 + '05-05', // 어린이날 + '06-06', // 현충일 + '08-15', // 광복절 + '10-03', // 개천절 + '10-09', // 한글날 + '12-25', // 성탄절 + ]; + + static bool isFixedKoreanHoliday(DateTime date) { + final key = '${_two(date.month)}-${_two(date.day)}'; + return _fixedHolidays.contains(key); + } + + static String _two(int n) => n.toString().padLeft(2, '0'); + + /// 입력 날짜가 주말/고정 공휴일이면 다음 영업일로 전진합니다. + static DateTime nextBusinessDay(DateTime date) { + var d = DateTime(date.year, date.month, date.day); + while (isWeekend(d) || isFixedKoreanHoliday(d)) { + d = d.add(const Duration(days: 1)); + } + return d; + } + + /// 대상 월의 말일을 반환합니다. + static int daysInMonth(int year, int month) => + DateTime(year, month + 1, 0).day; +} diff --git a/lib/widgets/add_subscription/add_subscription_app_bar.dart b/lib/widgets/add_subscription/add_subscription_app_bar.dart index d6d28d2..f2967b9 100644 --- a/lib/widgets/add_subscription/add_subscription_app_bar.dart +++ b/lib/widgets/add_subscription/add_subscription_app_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +// import 'package:flutter/foundation.dart' show kIsWeb; +// import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'dart:math' as math; import '../../controllers/add_subscription_controller.dart'; import '../../l10n/app_localizations.dart'; @@ -26,9 +26,11 @@ class AddSubscriptionAppBar extends StatelessWidget Widget build(BuildContext context) { final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100)); + final scheme = Theme.of(context).colorScheme; return Container( decoration: BoxDecoration( - color: Colors.white.withValues(alpha: appBarOpacity), + // Color adapts to current theme (light/dark) + color: scheme.surface.withValues(alpha: appBarOpacity), boxShadow: appBarOpacity > 0.6 ? [ BoxShadow( @@ -43,10 +45,10 @@ class AddSubscriptionAppBar extends StatelessWidget child: SafeArea( child: AppBar( leading: IconButton( - icon: const Icon( + icon: Icon( Icons.chevron_left, size: 28, - color: Color(0xFF1E293B), + color: Theme.of(context).colorScheme.onSurface, ), onPressed: () => Navigator.of(context).pop(), ), @@ -57,7 +59,7 @@ class AddSubscriptionAppBar extends StatelessWidget fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5, - color: const Color(0xFF1E293B), + color: Theme.of(context).colorScheme.onSurface, shadows: appBarOpacity > 0.6 ? [ Shadow( @@ -71,33 +73,8 @@ class AddSubscriptionAppBar extends StatelessWidget ), elevation: 0, backgroundColor: Colors.transparent, - actions: [ - if (!kIsWeb) - controller.isLoading - ? const Padding( - padding: EdgeInsets.only(right: 16.0), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Color(0xFF3B82F6)), - ), - ), - ), - ) - : IconButton( - icon: const FaIcon( - FontAwesomeIcons.message, - size: 20, - color: Color(0xFF3B82F6), - ), - onPressed: onScanSMS, - tooltip: AppLocalizations.of(context).scanTextMessages, - ), - ], + // SMS 스캔 버튼 제거: 우측 액션 비움 + actions: const [], ), ), ); diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart index b1cf6c5..4346e4d 100644 --- a/lib/widgets/add_subscription/add_subscription_event_section.dart +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../controllers/add_subscription_controller.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; -import '../../theme/app_colors.dart'; +// import '../../theme/app_colors.dart'; /// 구독 추가 화면의 이벤트/할인 섹션 class AddSubscriptionEventSection extends StatelessWidget { @@ -40,19 +40,13 @@ class AddSubscriptionEventSection extends StatelessWidget { margin: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: AppColors.glassCard, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), width: 1, ), - boxShadow: const [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -95,10 +89,10 @@ class AddSubscriptionEventSection extends StatelessWidget { } return Text( titleText, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ); }, @@ -118,7 +112,9 @@ class AddSubscriptionEventSection extends StatelessWidget { } }); }, - activeColor: controller.gradientColors[0], + activeThumbColor: controller.gradientColors[0], + activeTrackColor: + controller.gradientColors[0].withValues(alpha: 0.5), ), ], ), @@ -137,18 +133,24 @@ class AddSubscriptionEventSection extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.infoColor.withValues(alpha: 0.08), + color: Theme.of(context) + .colorScheme + .tertiary + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.infoColor.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .tertiary + .withValues(alpha: 0.3), width: 1, ), ), child: Row( children: [ - const Icon( + Icon( Icons.info_outline_rounded, - color: AppColors.infoColor, + color: Theme.of(context).colorScheme.tertiary, size: 20, ), const SizedBox(width: 8), @@ -174,9 +176,11 @@ class AddSubscriptionEventSection extends StatelessWidget { } return Text( infoText, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: AppColors.darkNavy, + color: Theme.of(context) + .colorScheme + .onSurface, fontWeight: FontWeight.w500, ), ); @@ -272,6 +276,10 @@ class AddSubscriptionEventSection extends StatelessWidget { currency: controller.currency, label: eventPriceLabel, hintText: eventPriceHint, + enabled: controller.isEventActive, + // 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리 + validator: + controller.isEventActive ? null : (_) => null, ); }, ), diff --git a/lib/widgets/add_subscription/add_subscription_form.dart b/lib/widgets/add_subscription/add_subscription_form.dart index 1371625..0d9e728 100644 --- a/lib/widgets/add_subscription/add_subscription_form.dart +++ b/lib/widgets/add_subscription/add_subscription_form.dart @@ -7,11 +7,11 @@ import '../../l10n/app_localizations.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; -import '../common/form_fields/currency_selector.dart'; +import '../common/form_fields/currency_dropdown_field.dart'; import '../common/form_fields/billing_cycle_selector.dart'; import '../common/form_fields/category_selector.dart'; -import '../glassmorphism_card.dart'; -import '../../theme/app_colors.dart'; +// Glass 제거: Material 3 Card 사용 +// Material colors only /// 구독 추가 화면의 폼 섹션 class AddSubscriptionForm extends StatelessWidget { @@ -45,8 +45,15 @@ class AddSubscriptionForm extends StatelessWidget { parent: controller.animationController!, curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), )), - child: GlassmorphismCard( - backgroundColor: AppColors.glassCard, + child: Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), child: Padding( padding: const EdgeInsets.all(24), child: Column( @@ -55,26 +62,19 @@ class AddSubscriptionForm extends StatelessWidget { // 헤더 Row( children: [ - ShaderMask( - shaderCallback: (bounds) => LinearGradient( - colors: controller.gradientColors, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(bounds), - child: const Icon( - FontAwesomeIcons.fileLines, - size: 20, - color: Colors.white, - ), + Icon( + FontAwesomeIcons.fileLines, + size: 20, + color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 12), Text( AppLocalizations.of(context).serviceInfo, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.5, - color: Color(0xFF1E293B), + color: Theme.of(context).colorScheme.onSurface, ), ), ], @@ -136,9 +136,8 @@ class AddSubscriptionForm extends StatelessWidget { ), ), const SizedBox(height: 8), - CurrencySelector( + CurrencyDropdownField( currency: controller.currency, - isGlassmorphism: true, onChanged: (value) { setState(() { controller.currency = value; @@ -158,8 +157,8 @@ class AddSubscriptionForm extends StatelessWidget { children: [ Text( AppLocalizations.of(context).billingCycle, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.w600, ), @@ -168,7 +167,6 @@ class AddSubscriptionForm extends StatelessWidget { BillingCycleSelector( billingCycle: controller.billingCycle, baseColor: controller.gradientColors[0], - isGlassmorphism: true, onChanged: (value) { setState(() { controller.billingCycle = value; @@ -203,7 +201,7 @@ class AddSubscriptionForm extends StatelessWidget { keyboardType: TextInputType.url, prefixIcon: Icon( Icons.link_rounded, - color: Colors.grey[600], + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 20), @@ -226,7 +224,6 @@ class AddSubscriptionForm extends StatelessWidget { categories: categoryProvider.categories, selectedCategoryId: controller.selectedCategoryId, baseColor: controller.gradientColors[0], - isGlassmorphism: true, onChanged: (categoryId) { setState(() { controller.selectedCategoryId = categoryId; diff --git a/lib/widgets/add_subscription/add_subscription_header.dart b/lib/widgets/add_subscription/add_subscription_header.dart index b91cea4..71215e3 100644 --- a/lib/widgets/add_subscription/add_subscription_header.dart +++ b/lib/widgets/add_subscription/add_subscription_header.dart @@ -26,19 +26,7 @@ class AddSubscriptionHeader extends StatelessWidget { padding: const EdgeInsets.all(24), decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - colors: controller.gradientColors, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: controller.gradientColors[0].withValues(alpha: 0.3), - blurRadius: 20, - spreadRadius: 0, - offset: const Offset(0, 8), - ), - ], + color: Theme.of(context).colorScheme.primary, ), child: Row( children: [ @@ -48,10 +36,10 @@ class AddSubscriptionHeader extends StatelessWidget { color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16), ), - child: const Icon( + child: Icon( Icons.add_rounded, size: 32, - color: Colors.white, + color: Theme.of(context).colorScheme.onPrimary, ), ), const SizedBox(width: 16), @@ -61,20 +49,23 @@ class AddSubscriptionHeader extends StatelessWidget { children: [ Text( AppLocalizations.of(context).newSubscriptionAdd, - style: const TextStyle( + style: TextStyle( fontSize: 24, fontWeight: FontWeight.w800, - color: Colors.white, + color: Theme.of(context).colorScheme.onPrimary, letterSpacing: -0.5, ), ), const SizedBox(height: 4), Text( AppLocalizations.of(context).enterServiceInfo, - style: const TextStyle( + style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: Colors.white70, + color: Theme.of(context) + .colorScheme + .onPrimary + .withValues(alpha: 0.7), ), ), ], diff --git a/lib/widgets/add_subscription/add_subscription_save_button.dart b/lib/widgets/add_subscription/add_subscription_save_button.dart index 6de05cc..b6f32c3 100644 --- a/lib/widgets/add_subscription/add_subscription_save_button.dart +++ b/lib/widgets/add_subscription/add_subscription_save_button.dart @@ -44,7 +44,7 @@ class AddSubscriptionSaveButton extends StatelessWidget { ? null : () => controller.saveSubscription(setState: setState), isLoading: controller.isLoading, - backgroundColor: const Color(0xFF3B82F6), + backgroundColor: Theme.of(context).colorScheme.primary, ), ), ), diff --git a/lib/widgets/analysis/analysis_badge.dart b/lib/widgets/analysis/analysis_badge.dart index 0317577..67a9d09 100644 --- a/lib/widgets/analysis/analysis_badge.dart +++ b/lib/widgets/analysis/analysis_badge.dart @@ -4,7 +4,7 @@ import 'package:provider/provider.dart'; import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; import '../../providers/locale_provider.dart'; -import '../../theme/app_colors.dart'; +// import '../../theme/app_colors.dart'; /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 class AnalysisBadge extends StatelessWidget { @@ -26,15 +26,15 @@ class AnalysisBadge extends StatelessWidget { width: size, height: size, decoration: BoxDecoration( - color: AppColors.pureWhite, + color: Theme.of(context).colorScheme.surface, shape: BoxShape.circle, border: Border.all( color: borderColor, width: 2, ), - boxShadow: const [ + boxShadow: [ BoxShadow( - color: AppColors.shadowBlack, + color: Colors.black.withValues(alpha: 0.08), blurRadius: 10, spreadRadius: 2, ), @@ -48,10 +48,10 @@ class AnalysisBadge extends StatelessWidget { subscription.serviceName.length > 5 ? '${subscription.serviceName.substring(0, 5)}...' : subscription.serviceName, - style: const TextStyle( + style: TextStyle( fontSize: 8, fontWeight: FontWeight.bold, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 0), @@ -82,9 +82,9 @@ class AnalysisBadge extends StatelessWidget { } return Text( displayText, - style: const TextStyle( + style: TextStyle( fontSize: 7, - color: AppColors.navyGray, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } diff --git a/lib/widgets/analysis/event_analysis_card.dart b/lib/widgets/analysis/event_analysis_card.dart index 1b2e01f..846cacc 100644 --- a/lib/widgets/analysis/event_analysis_card.dart +++ b/lib/widgets/analysis/event_analysis_card.dart @@ -3,10 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import '../../providers/subscription_provider.dart'; import '../../services/currency_util.dart'; -import '../../theme/app_colors.dart'; -import '../glassmorphism_card.dart'; +// Glass 제거: Material 3 Card 사용 import '../themed_text.dart'; import '../../l10n/app_localizations.dart'; +import '../../theme/color_scheme_ext.dart'; /// 이벤트 할인 현황을 보여주는 카드 위젯 class EventAnalysisCard extends StatelessWidget { @@ -38,10 +38,17 @@ class EventAnalysisCard extends StatelessWidget { parent: animationController, curve: const Interval(0.6, 1.0, curve: Curves.easeOut), )), - child: GlassmorphismCard( - blur: 10, - opacity: 0.1, - borderRadius: 16, + child: Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -64,20 +71,18 @@ class EventAnalysisCard extends StatelessWidget { vertical: 4, ), decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFFFF6B6B), - Color(0xFFFE7E7E), - ], - ), + color: + Theme.of(context).colorScheme.error, borderRadius: BorderRadius.circular(4), ), child: Row( children: [ - const FaIcon( + FaIcon( FontAwesomeIcons.fire, size: 12, - color: AppColors.pureWhite, + color: Theme.of(context) + .colorScheme + .onError, ), const SizedBox(width: 4), Text( @@ -85,10 +90,12 @@ class EventAnalysisCard extends StatelessWidget { .servicesInProgress(provider .activeEventSubscriptions .length), - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, - color: AppColors.pureWhite, + color: Theme.of(context) + .colorScheme + .onError, ), ), ], @@ -100,27 +107,24 @@ class EventAnalysisCard extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFFFF6B6B) - .withValues(alpha: 0.1), - const Color(0xFFFF8787) - .withValues(alpha: 0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + color: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8), border: Border.all( - color: const Color(0xFFFF6B6B) + color: Theme.of(context) + .colorScheme + .error .withValues(alpha: 0.3), ), ), child: Row( children: [ - const Icon( + Icon( Icons.savings, - color: Color(0xFFFF6B6B), + color: + Theme.of(context).colorScheme.error, size: 32, ), const SizedBox(width: 12), @@ -142,10 +146,12 @@ class EventAnalysisCard extends StatelessWidget { CurrencyUtil.formatTotalAmount( provider.calculateTotalSavings(), ), - style: const TextStyle( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: Color(0xFFFF6B6B), + color: Theme.of(context) + .colorScheme + .error, ), ), ], @@ -173,12 +179,16 @@ class EventAnalysisCard extends StatelessWidget { margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.darkNavy + color: Theme.of(context) + .colorScheme + .onSurface .withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), border: Border.all( - color: AppColors.darkNavy - .withValues(alpha: 0.1), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.2), ), ), child: Row( @@ -207,13 +217,15 @@ class EventAnalysisCard extends StatelessWidget { if (snapshot.hasData) { return ThemedText( snapshot.data!, - style: const TextStyle( + style: TextStyle( fontSize: 12, decoration: TextDecoration .lineThrough, - color: AppColors - .navyGray, + color: Theme.of( + context) + .colorScheme + .onSurfaceVariant, ), ); } @@ -221,10 +233,12 @@ class EventAnalysisCard extends StatelessWidget { }, ), const SizedBox(width: 8), - const Icon( + Icon( Icons.arrow_forward, size: 12, - color: AppColors.navyGray, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), const SizedBox(width: 8), FutureBuilder( @@ -237,12 +251,14 @@ class EventAnalysisCard extends StatelessWidget { if (snapshot.hasData) { return ThemedText( snapshot.data!, - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: - Color(0xFF10B981), + Theme.of(context) + .colorScheme + .success, ), ); } @@ -260,17 +276,22 @@ class EventAnalysisCard extends StatelessWidget { vertical: 4, ), decoration: BoxDecoration( - color: const Color(0xFFFF6B6B) + color: Theme.of(context) + .colorScheme + .error .withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: Text( - '$discountRate${AppLocalizations.of(context).discountPercent}', - style: const TextStyle( + _formatDiscountPercent( + context, discountRate), + style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, - color: Color(0xFFFF6B6B), + color: Theme.of(context) + .colorScheme + .error, ), ), ), @@ -291,3 +312,17 @@ class EventAnalysisCard extends StatelessWidget { ); } } + +String _formatDiscountPercent(BuildContext context, int percent) { + final raw = AppLocalizations.of(context).discountPercent; + // 우선 @ 플레이스홀더가 있으면 치환 + if (raw.contains('@')) { + return raw.replaceAll('@', percent.toString()); + } + // % 마커가 있으면 첫 번째 %를 숫자%로 치환 + if (raw.contains('%')) { + return raw.replaceFirst('%', '$percent%'); + } + // 폴백: "99% text" 형태 + return '$percent% $raw'; +} diff --git a/lib/widgets/analysis/monthly_expense_chart_card.dart b/lib/widgets/analysis/monthly_expense_chart_card.dart index 30eb01e..036f43b 100644 --- a/lib/widgets/analysis/monthly_expense_chart_card.dart +++ b/lib/widgets/analysis/monthly_expense_chart_card.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; +import '../../theme/color_scheme_ext.dart'; import 'package:fl_chart/fl_chart.dart'; import 'dart:math' as math; import 'package:provider/provider.dart'; import '../../services/currency_util.dart'; import '../../providers/locale_provider.dart'; -import '../../theme/app_colors.dart'; -import '../glassmorphism_card.dart'; +// Glass 제거: Material 3 Card 사용 import '../themed_text.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/reduce_motion.dart'; @@ -75,11 +75,13 @@ class MonthlyExpenseChartCard extends StatelessWidget { } // 월간 지출 차트 데이터 - List _getMonthlyBarGroups(String locale) { + List _getMonthlyBarGroups( + BuildContext context, String locale) { final List barGroups = []; final calculatedMax = monthlyData.fold( 0, (max, data) => math.max(max, data['totalExpense'] as double)); final maxAmount = _calculateChartMaxY(calculatedMax, locale); + final scheme = Theme.of(context).colorScheme; for (int i = 0; i < monthlyData.length; i++) { final data = monthlyData[i]; @@ -89,20 +91,13 @@ class MonthlyExpenseChartCard extends StatelessWidget { barRods: [ BarChartRodData( toY: data['totalExpense'], - gradient: LinearGradient( - colors: [ - const Color(0xFF3B82F6).withValues(alpha: 0.7), - const Color(0xFF60A5FA), - ], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), + color: scheme.primary, width: 18, borderRadius: BorderRadius.circular(4), backDrawRodData: BackgroundBarChartRodData( show: true, toY: maxAmount, - color: AppColors.navyGray.withValues(alpha: 0.1), + color: scheme.onSurfaceVariant.withValues(alpha: 0.08), ), ), ], @@ -132,10 +127,17 @@ class MonthlyExpenseChartCard extends StatelessWidget { parent: animationController, curve: const Interval(0.4, 0.9, curve: Curves.easeOut), )), - child: GlassmorphismCard( - blur: 10, - opacity: 0.1, - borderRadius: 16, + child: Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -168,7 +170,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { (max, data) => math.max( max, data['totalExpense'] as double)), locale), - barGroups: _getMonthlyBarGroups(locale), + barGroups: _getMonthlyBarGroups(context, locale), gridData: FlGridData( show: true, drawVerticalLine: false, @@ -182,8 +184,10 @@ class MonthlyExpenseChartCard extends StatelessWidget { CurrencyUtil.getDefaultCurrency(locale)), getDrawingHorizontalLine: (value) { return FlLine( - color: - AppColors.navyGray.withValues(alpha: 0.1), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.1), strokeWidth: 1, ); }, @@ -222,14 +226,18 @@ class MonthlyExpenseChartCard extends StatelessWidget { barTouchData: BarTouchData( enabled: true, touchTooltipData: BarTouchTooltipData( - tooltipBgColor: AppColors.darkNavy, + tooltipBgColor: Theme.of(context) + .colorScheme + .inverseSurface, tooltipRoundedRadius: 8, getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( '${monthlyData[group.x]['monthName']}\n', - const TextStyle( - color: AppColors.pureWhite, + TextStyle( + color: Theme.of(context) + .colorScheme + .onInverseSurface, fontWeight: FontWeight.bold, ), children: [ @@ -239,8 +247,10 @@ class MonthlyExpenseChartCard extends StatelessWidget { monthlyData[group.x] ['totalExpense'] as double, locale), - style: const TextStyle( - color: Color(0xFFFBBF24), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .warning, fontSize: 14, fontWeight: FontWeight.w500, ), diff --git a/lib/widgets/analysis/subscription_pie_chart_card.dart b/lib/widgets/analysis/subscription_pie_chart_card.dart index 37a40c4..3c9c27d 100644 --- a/lib/widgets/analysis/subscription_pie_chart_card.dart +++ b/lib/widgets/analysis/subscription_pie_chart_card.dart @@ -4,8 +4,9 @@ import 'package:provider/provider.dart'; import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; import '../../services/exchange_rate_service.dart'; -import '../../theme/app_colors.dart'; -import '../glassmorphism_card.dart'; +// import '../../theme/app_colors.dart'; +import '../../theme/color_scheme_ext.dart'; +// Glass 제거: Material 3 Card 사용 import '../themed_text.dart'; import 'analysis_badge.dart'; import '../../l10n/app_localizations.dart'; @@ -30,18 +31,19 @@ class SubscriptionPieChartCard extends StatefulWidget { class _SubscriptionPieChartCardState extends State { int _touchedIndex = -1; - late Future> _pieSectionsFuture; + // kept for compatibility previously; computation now happens per build String? _lastLocale; - static const _chartColors = [ - Color(0xFF3B82F6), - Color(0xFF10B981), - Color(0xFFF59E0B), - Color(0xFFEF4444), - Color(0xFF8B5CF6), - Color(0xFF0EA5E9), - Color(0xFFEC4899), - ]; + // 차트 팔레트: ColorScheme + 보조 상수(성공/경고/액센트) + List _getChartColors(ColorScheme scheme) => [ + scheme.primary, + scheme.success, + scheme.warning, + scheme.error, + scheme.tertiary, + scheme.secondary, + const Color(0xFFEC4899), // accent + ]; @override void initState() { @@ -62,7 +64,7 @@ class _SubscriptionPieChartCardState extends State { void _initializeFuture() { _lastLocale = context.read().locale.languageCode; - _pieSectionsFuture = _getPieSections(); + // no-op: Future computed on demand in build } bool _listEquals(List a, List b) { @@ -85,6 +87,9 @@ class _SubscriptionPieChartCardState extends State { // 현재 locale 가져오기 final locale = context.read().locale.languageCode; final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); + // Chart palette (capture scheme before any awaits) + final scheme = Theme.of(context).colorScheme; + final chartColors = _getChartColors(scheme); // 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산) List sectionValues = []; @@ -121,7 +126,7 @@ class _SubscriptionPieChartCardState extends State { // 섹션 데이터 생성 (터치 상태 제외) final sections = List.generate(widget.subscriptions.length, (i) { final percentage = (sectionValues[i] / sectionsTotal) * 100; - final index = i % _chartColors.length; + final index = i % chartColors.length; return PieChartSectionData( value: sectionValues[i], @@ -129,12 +134,12 @@ class _SubscriptionPieChartCardState extends State { titleStyle: const TextStyle( fontSize: 12.0, fontWeight: FontWeight.bold, - color: AppColors.pureWhite, + color: Colors.white, shadows: [ Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) ], ), - color: _chartColors[index], + color: chartColors[index], radius: 100.0, titlePositionPercentageOffset: 0.6, badgeWidget: null, @@ -150,12 +155,13 @@ class _SubscriptionPieChartCardState extends State { if (index >= widget.subscriptions.length) return const SizedBox.shrink(); final subscription = widget.subscriptions[index]; - final colorIndex = index % _chartColors.length; + final chartColors = _getChartColors(Theme.of(context).colorScheme); + final colorIndex = index % chartColors.length; return IgnorePointer( child: AnalysisBadge( size: 40, - borderColor: _chartColors[colorIndex], + borderColor: chartColors[colorIndex], subscription: subscription, ), ); @@ -177,7 +183,7 @@ class _SubscriptionPieChartCardState extends State { TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, - color: AppColors.pureWhite, + color: Colors.white, shadows: const [ Shadow( color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) @@ -210,10 +216,17 @@ class _SubscriptionPieChartCardState extends State { parent: widget.animationController, curve: const Interval(0.0, 0.7, curve: Curves.easeOut), )), - child: GlassmorphismCard( - blur: 10, - opacity: 0.1, - borderRadius: 16, + child: Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -243,20 +256,27 @@ class _SubscriptionPieChartCardState extends State { vertical: 4, ), decoration: BoxDecoration( - color: const Color(0xFFE5F2FF), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(4), border: Border.all( - color: const Color(0xFFBFDBFE), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), width: 1, ), ), child: Text( AppLocalizations.of(context) .exchangeRateFormat(snapshot.data!), - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), + color: + Theme.of(context).colorScheme.primary, ), ), ); @@ -291,7 +311,7 @@ class _SubscriptionPieChartCardState extends State { : SizedBox( height: 250, child: FutureBuilder>( - future: _pieSectionsFuture, + future: _getPieSections(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -392,8 +412,10 @@ class _SubscriptionPieChartCardState extends State { (index) { final subscription = widget.subscriptions[index]; + final chartColors = _getChartColors( + Theme.of(context).colorScheme); final color = - _chartColors[index % _chartColors.length]; + chartColors[index % chartColors.length]; return Padding( padding: const EdgeInsets.only(bottom: 4.0), child: Row( diff --git a/lib/widgets/analysis/total_expense_summary_card.dart b/lib/widgets/analysis/total_expense_summary_card.dart index 232ad0c..a368901 100644 --- a/lib/widgets/analysis/total_expense_summary_card.dart +++ b/lib/widgets/analysis/total_expense_summary_card.dart @@ -6,8 +6,9 @@ import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; import '../../providers/locale_provider.dart'; import '../../utils/haptic_feedback_helper.dart'; -import '../../theme/app_colors.dart'; -import '../glassmorphism_card.dart'; +// import '../../theme/app_colors.dart'; +import '../../theme/color_scheme_ext.dart'; +// Glass 제거: Material 3 Card 사용 import '../themed_text.dart'; import '../../l10n/app_localizations.dart'; @@ -44,10 +45,17 @@ class TotalExpenseSummaryCard extends StatelessWidget { curve: const Interval(0.2, 0.8, curve: Curves.easeOut), )), child: RepaintBoundary( - child: GlassmorphismCard( - blur: 10, - opacity: 0.1, - borderRadius: 16, + child: Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -85,8 +93,6 @@ class TotalExpenseSummaryCard extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - backgroundColor: AppColors.glassBackground - .withValues(alpha: 0.3), margin: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, @@ -142,18 +148,24 @@ class TotalExpenseSummaryCard extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.glassBackground - .withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.4), borderRadius: BorderRadius.circular(8), border: Border.all( - color: AppColors.glassBorder - .withValues(alpha: 0.2), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3), ), ), - child: const FaIcon( + child: FaIcon( FontAwesomeIcons.listCheck, size: 16, - color: AppColors.primaryColor, + color: Theme.of(context) + .colorScheme + .primary, ), ), const SizedBox(width: 12), @@ -189,18 +201,24 @@ class TotalExpenseSummaryCard extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.glassBackground - .withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.4), borderRadius: BorderRadius.circular(8), border: Border.all( - color: AppColors.glassBorder - .withValues(alpha: 0.2), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3), ), ), - child: const FaIcon( + child: FaIcon( FontAwesomeIcons.chartLine, size: 16, - color: AppColors.successColor, + color: Theme.of(context) + .colorScheme + .success, ), ), const SizedBox(width: 12), diff --git a/lib/widgets/animated_page_transitions.dart b/lib/widgets/animated_page_transitions.dart index 43dc8c3..282b5ca 100644 --- a/lib/widgets/animated_page_transitions.dart +++ b/lib/widgets/animated_page_transitions.dart @@ -130,9 +130,11 @@ class RotatePageRoute extends PageRouteBuilder { alignment: Alignment.center, transform: Matrix4.identity() ..setEntry(3, 2, 0.001) - ..rotateZ(rotateAnimation.value) - ..scale(scaleAnimation.value), - child: child, + ..rotateZ(rotateAnimation.value), + child: Transform.scale( + scale: scaleAnimation.value, + child: child, + ), ); }, ); @@ -219,7 +221,10 @@ class ContainerTransformPageRoute extends PageRouteBuilder { FadeTransition( opacity: animation, child: Container( - color: Colors.black.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .scrim + .withValues(alpha: 0.3), ), ), // 컨테이너 확장 애니메이션 diff --git a/lib/widgets/animated_wave_background.dart b/lib/widgets/animated_wave_background.dart index 76141cd..12accaf 100644 --- a/lib/widgets/animated_wave_background.dart +++ b/lib/widgets/animated_wave_background.dart @@ -18,7 +18,21 @@ class AnimatedWaveBackground extends StatelessWidget { @override Widget build(BuildContext context) { final reduce = ReduceMotion.isEnabled(context); - final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일 + final amp = reduce ? 0.3 : 1.0; // 기본 효과 강도 스케일 + + // 원 크기에 따라 속도/진폭 스케일을 동적으로 계산 + // size가 클수록 느리고(차분), 작을수록 빠르고(활발) 크게 움직이게 함 + MotionParams paramsFor(double size) { + const ref = 160.0; // 기준 크기 + // 진폭 스케일: 0.6 ~ 1.4 사이 (연속) + final ampScale = _clamp(ref / size, 0.6, 1.4) * (reduce ? 0.6 : 1.0); + // 속도 배수: 1~3의 정수로 제한하여 래핑 시 연속성 보장 + final raw = 0.8 + (ref / size) * 0.6; // 약 0.8~1.4 범위 + int speedMult = raw < 1.2 ? 1 : (raw < 1.6 ? 2 : 3); + if (reduce && speedMult > 2) speedMult = 2; // 감속 모드 상한 + return MotionParams(ampScale: ampScale, speedMult: speedMult); + } + return Stack( children: [ // 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용 @@ -26,22 +40,26 @@ class AnimatedWaveBackground extends StatelessWidget { animation: controller, builder: (context, child) { // 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성 - final angle = controller.value * 2 * math.pi; - // 사인 함수를 사용하여 부드러운 움직임 생성 - final xOffset = 20 * amp * math.sin(angle); - final yOffset = 10 * amp * math.cos(angle); + final p = paramsFor(200); + final angle = controller.value * 2 * math.pi * p.speedMult; + // 사인 함수를 사용하여 부드러운 움직임 생성 (큰 원: 차분) + final xOffset = 20 * amp * p.ampScale * math.sin(angle); + final yOffset = 10 * amp * p.ampScale * math.cos(angle); return Positioned( right: -40 + xOffset, top: -60 + yOffset, child: Transform.rotate( // 회전도 선형적으로 변화하도록 수정 - angle: 0.2 * amp * math.sin(angle * 0.5), + angle: 0.2 * amp * p.ampScale * math.sin(angle * 0.5), child: Container( width: 200, height: 200, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(100), ), ), @@ -53,21 +71,26 @@ class AnimatedWaveBackground extends StatelessWidget { animation: controller, builder: (context, child) { // 첫 번째 원과 약간 다른 위상을 가지도록 설정 - final angle = (controller.value * 2 * math.pi) + (math.pi / 3); - final xOffset = 20 * amp * math.cos(angle); - final yOffset = 10 * amp * math.sin(angle); + final p = paramsFor(220); + final angle = + (controller.value * 2 * math.pi * p.speedMult) + (math.pi / 3); + final xOffset = 20 * amp * p.ampScale * math.cos(angle); + final yOffset = 10 * amp * p.ampScale * math.sin(angle); return Positioned( left: -80 + xOffset, bottom: -70 + yOffset, child: Transform.rotate( // 반대 방향으로 회전하도록 설정 - angle: -0.3 * amp * math.sin(angle * 0.5), + angle: -0.3 * amp * p.ampScale * math.sin(angle * 0.5), child: Container( width: 220, height: 220, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.05), borderRadius: BorderRadius.circular(110), ), ), @@ -80,20 +103,25 @@ class AnimatedWaveBackground extends StatelessWidget { animation: controller, builder: (context, child) { // 세 번째 원은 다른 위상으로 움직이도록 설정 - final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3); - final xOffset = 15 * amp * math.sin(angle * 0.7); - final yOffset = 8 * amp * math.cos(angle * 0.7); + final p = paramsFor(120); + final angle = (controller.value * 2 * math.pi * p.speedMult) + + (math.pi * 2 / 3); + final xOffset = 15 * amp * p.ampScale * math.sin(angle * 0.9); + final yOffset = 8 * amp * p.ampScale * math.cos(angle * 0.9); return Positioned( right: 40 + xOffset, bottom: -40 + yOffset, child: Transform.rotate( - angle: 0.4 * amp * math.cos(angle * 0.5), + angle: 0.4 * amp * p.ampScale * math.cos(angle * 0.5), child: Container( width: 120, height: 120, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.08), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.06), borderRadius: BorderRadius.circular(60), ), ), @@ -112,7 +140,7 @@ class AnimatedWaveBackground extends StatelessWidget { width: 30, height: 30, decoration: BoxDecoration( - color: Colors.white.withValues( + color: Theme.of(context).colorScheme.onSurface.withValues( alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value), borderRadius: BorderRadius.circular(15), ), @@ -124,3 +152,13 @@ class AnimatedWaveBackground extends StatelessWidget { ); } } + +// 내부 유틸리티: 값 범위 제한 +double _clamp(double v, double min, double max) => + v < min ? min : (v > max ? max : v); + +class MotionParams { + final double ampScale; + final int speedMult; + MotionParams({required this.ampScale, required this.speedMult}); +} diff --git a/lib/widgets/category_header_widget.dart b/lib/widgets/category_header_widget.dart index dddba5e..28ead74 100644 --- a/lib/widgets/category_header_widget.dart +++ b/lib/widgets/category_header_widget.dart @@ -36,27 +36,27 @@ class CategoryHeaderWidget extends StatelessWidget { children: [ Text( categoryName, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, - color: Color(0xFF374151), + color: Theme.of(context).colorScheme.onSurface, ), ), Text( _buildCostDisplay(context), - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: Color(0xFF6B7280), + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: 8), - const Divider( + Divider( height: 1, thickness: 1, - color: Color(0xFFEEEEEE), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), ), ], ), diff --git a/lib/widgets/common/buttons/primary_button.dart b/lib/widgets/common/buttons/primary_button.dart index fa748d8..638c551 100644 --- a/lib/widgets/common/buttons/primary_button.dart +++ b/lib/widgets/common/buttons/primary_button.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../../../theme/app_colors.dart'; /// 주요 액션에 사용되는 Primary 버튼 /// 저장, 추가, 확인 등의 주요 액션에 사용됩니다. @@ -44,26 +43,30 @@ class _PrimaryButtonState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final effectiveBackgroundColor = - widget.backgroundColor ?? theme.primaryColor; + widget.backgroundColor ?? theme.colorScheme.primary; final effectiveForegroundColor = - widget.foregroundColor ?? AppColors.pureWhite; + widget.foregroundColor ?? theme.colorScheme.onPrimary; Widget button = AnimatedContainer( duration: const Duration(milliseconds: 200), width: widget.width ?? double.infinity, height: widget.height, transform: widget.enableHoverEffect && _isHovered - ? (Matrix4.identity()..scale(1.02)) + ? Matrix4.diagonal3Values(1.02, 1.02, 1.0) : Matrix4.identity(), child: ElevatedButton( onPressed: widget.isLoading ? null : widget.onPressed, style: ElevatedButton.styleFrom( backgroundColor: effectiveBackgroundColor, foregroundColor: effectiveForegroundColor, + // 고정 높이와 텍스트 잘림 방지를 위해 최소 사이즈 지정 + minimumSize: Size.fromHeight(widget.height), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(widget.borderRadius), ), - padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), + // 컨테이너에서 높이를 관리하므로 수직 패딩은 0으로 두고 + // 수평 여백만 부여하여 작은 높이(예: 48)에서 글자 잘림 방지 + padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 16), elevation: widget.enableHoverEffect && _isHovered ? 2 : 0, shadowColor: Colors.black.withValues(alpha: 0.08), disabledBackgroundColor: diff --git a/lib/widgets/common/buttons/secondary_button.dart b/lib/widgets/common/buttons/secondary_button.dart index 3f31271..45639ee 100644 --- a/lib/widgets/common/buttons/secondary_button.dart +++ b/lib/widgets/common/buttons/secondary_button.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../../../theme/app_colors.dart'; /// 부차적인 액션에 사용되는 Secondary 버튼 /// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다. @@ -42,15 +41,17 @@ class _SecondaryButtonState extends State { @override Widget build(BuildContext context) { - final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; - final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; + final theme = Theme.of(context); + final effectiveBorderColor = + widget.borderColor ?? theme.colorScheme.outline; + final effectiveTextColor = widget.textColor ?? theme.colorScheme.primary; Widget button = AnimatedContainer( duration: const Duration(milliseconds: 200), width: widget.width, height: widget.height, transform: widget.enableHoverEffect && _isHovered - ? (Matrix4.identity()..scale(1.02)) + ? Matrix4.diagonal3Values(1.02, 1.02, 1.0) : Matrix4.identity(), child: OutlinedButton( onPressed: widget.onPressed, @@ -70,8 +71,9 @@ class _SecondaryButtonState extends State { vertical: 12, horizontal: 24, ), - backgroundColor: - _isHovered ? AppColors.glassBackground : Colors.transparent, + backgroundColor: _isHovered + ? theme.colorScheme.onSurface.withValues(alpha: 0.06) + : Colors.transparent, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -140,7 +142,7 @@ class _TextLinkButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final effectiveColor = widget.color ?? AppColors.primaryColor; + final effectiveColor = widget.color ?? theme.colorScheme.primary; Widget button = AnimatedContainer( duration: const Duration(milliseconds: 200), diff --git a/lib/widgets/common/dialogs/confirmation_dialog.dart b/lib/widgets/common/dialogs/confirmation_dialog.dart index 8b819dc..75693ad 100644 --- a/lib/widgets/common/dialogs/confirmation_dialog.dart +++ b/lib/widgets/common/dialogs/confirmation_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../../theme/color_scheme_ext.dart'; /// 확인 다이얼로그 위젯 /// 사용자에게 중요한 작업을 확인받을 때 사용합니다. @@ -99,7 +100,9 @@ class ConfirmationDialog extends StatelessWidget { ), child: Text( confirmText, - style: const TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + ), ), ), ], @@ -164,12 +167,13 @@ class SuccessDialog extends StatelessWidget { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), + color: + Theme.of(context).colorScheme.success.withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.check_circle, - color: Colors.green, + color: Theme.of(context).colorScheme.success, size: 64, ), ), @@ -188,7 +192,7 @@ class SuccessDialog extends StatelessWidget { message!, style: TextStyle( fontSize: 16, - color: Colors.grey[600], + color: Theme.of(context).colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), @@ -203,7 +207,7 @@ class SuccessDialog extends StatelessWidget { onPressed?.call(); }, style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, + backgroundColor: Theme.of(context).colorScheme.success, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -214,8 +218,8 @@ class SuccessDialog extends StatelessWidget { ), child: Text( buttonText, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, fontSize: 16, ), ), @@ -272,12 +276,12 @@ class ErrorDialog extends StatelessWidget { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), + color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.error_outline, - color: Colors.red, + color: Theme.of(context).colorScheme.error, size: 64, ), ), @@ -296,7 +300,7 @@ class ErrorDialog extends StatelessWidget { message!, style: TextStyle( fontSize: 16, - color: Colors.grey[600], + color: Theme.of(context).colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), @@ -311,7 +315,7 @@ class ErrorDialog extends StatelessWidget { onPressed?.call(); }, style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, + backgroundColor: Theme.of(context).colorScheme.error, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -322,8 +326,8 @@ class ErrorDialog extends StatelessWidget { ), child: Text( buttonText, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, fontSize: 16, ), ), diff --git a/lib/widgets/common/form_fields/base_text_field.dart b/lib/widgets/common/form_fields/base_text_field.dart index 63d685a..c08c14c 100644 --- a/lib/widgets/common/form_fields/base_text_field.dart +++ b/lib/widgets/common/form_fields/base_text_field.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../../theme/app_colors.dart'; +// import '../../../theme/app_colors.dart'; /// 공통 텍스트 필드 위젯 /// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다. @@ -66,10 +66,10 @@ class BaseTextField extends StatelessWidget { if (label != null) ...[ Text( label!, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.textSecondary, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), @@ -89,22 +89,22 @@ class BaseTextField extends StatelessWidget { maxLines: maxLines, minLines: minLines, readOnly: readOnly, - cursorColor: cursorColor ?? theme.primaryColor, + cursorColor: cursorColor ?? theme.colorScheme.primary, style: style ?? - const TextStyle( + TextStyle( fontSize: 16, - color: AppColors.textPrimary, + color: Theme.of(context).colorScheme.onSurface, ), decoration: InputDecoration( hintText: hintText, - hintStyle: const TextStyle( - color: AppColors.textMuted, + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), prefixIcon: prefixIcon, prefixText: prefixText, suffixIcon: suffixIcon, filled: true, - fillColor: fillColor ?? AppColors.surfaceColorAlt, + fillColor: fillColor ?? Theme.of(context).colorScheme.surface, contentPadding: contentPadding ?? const EdgeInsets.all(16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -113,15 +113,15 @@ class BaseTextField extends StatelessWidget { focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide( - color: theme.primaryColor, + color: theme.colorScheme.primary, width: 2, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide( - color: AppColors.borderColor.withValues(alpha: 0.7), - width: 1.5, + color: theme.colorScheme.outline.withValues(alpha: 0.6), + width: 1, ), ), disabledBorder: OutlineInputBorder( diff --git a/lib/widgets/common/form_fields/billing_cycle_selector.dart b/lib/widgets/common/form_fields/billing_cycle_selector.dart index 98da23e..6e737f8 100644 --- a/lib/widgets/common/form_fields/billing_cycle_selector.dart +++ b/lib/widgets/common/form_fields/billing_cycle_selector.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../../theme/app_colors.dart'; +// import '../../../theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; /// 결제 주기 선택 위젯 @@ -8,8 +8,8 @@ class BillingCycleSelector extends StatelessWidget { final String billingCycle; final ValueChanged onChanged; final Color? baseColor; - final List? gradientColors; - final bool isGlassmorphism; + final List? gradientColors; // deprecated: ignored + final bool isGlassmorphism; // deprecated: ignored const BillingCycleSelector({ super.key, @@ -24,19 +24,12 @@ class BillingCycleSelector extends StatelessWidget { Widget build(BuildContext context) { final localization = AppLocalizations.of(context); // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 - final cycles = isGlassmorphism - ? [ - localization.billingCycleMonthly, - localization.billingCycleQuarterly, - localization.billingCycleHalfYearly, - localization.billingCycleYearly, - ] - : [ - localization.monthly, - localization.billingCycleQuarterly, - localization.billingCycleHalfYearly, - localization.yearly, - ]; + final cycles = [ + localization.monthly, + localization.billingCycleQuarterly, + localization.billingCycleHalfYearly, + localization.yearly, + ]; return SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -54,16 +47,16 @@ class BillingCycleSelector extends StatelessWidget { vertical: 12, ), decoration: BoxDecoration( - color: _getBackgroundColor(isSelected), + color: _getBackgroundColor(context, isSelected), borderRadius: BorderRadius.circular(12), - border: _getBorder(isSelected), + border: _getBorder(context, isSelected), ), child: Text( cycle, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: _getTextColor(isSelected), + color: _getTextColor(context, isSelected), ), ), ), @@ -74,38 +67,22 @@ class BillingCycleSelector extends StatelessWidget { ); } - Color _getBackgroundColor(bool isSelected) { - if (!isSelected) { - return isGlassmorphism - ? AppColors.backgroundColor - : Colors.grey.withValues(alpha: 0.1); - } - - if (baseColor != null) { - return baseColor!; - } - - if (gradientColors != null && gradientColors!.isNotEmpty) { - return gradientColors![0]; - } - - return const Color(0xFF3B82F6); - } - - Border? _getBorder(bool isSelected) { - if (isSelected || !isGlassmorphism) { - return null; - } - return Border.all( - color: AppColors.borderColor.withValues(alpha: 0.5), - width: 1.5, - ); - } - - Color _getTextColor(bool isSelected) { + Color _getBackgroundColor(BuildContext context, bool isSelected) { + final scheme = Theme.of(context).colorScheme; if (isSelected) { - return Colors.white; + return baseColor ?? scheme.primary; } - return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!; + return scheme.surface; + } + + Border? _getBorder(BuildContext context, bool isSelected) { + final scheme = Theme.of(context).colorScheme; + if (isSelected) return null; + return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1); + } + + Color _getTextColor(BuildContext context, bool isSelected) { + final scheme = Theme.of(context).colorScheme; + return isSelected ? scheme.onPrimary : scheme.onSurface; } } diff --git a/lib/widgets/common/form_fields/category_selector.dart b/lib/widgets/common/form_fields/category_selector.dart index e6904c6..8424c00 100644 --- a/lib/widgets/common/form_fields/category_selector.dart +++ b/lib/widgets/common/form_fields/category_selector.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../../theme/app_colors.dart'; +// import '../../../theme/app_colors.dart'; import '../../../providers/category_provider.dart'; /// 카테고리 선택 위젯 @@ -10,8 +10,8 @@ class CategorySelector extends StatelessWidget { final String? selectedCategoryId; final ValueChanged onChanged; final Color? baseColor; - final List? gradientColors; - final bool isGlassmorphism; + final List? gradientColors; // deprecated: ignored + final bool isGlassmorphism; // deprecated: ignored const CategorySelector({ super.key, @@ -39,9 +39,9 @@ class CategorySelector extends StatelessWidget { vertical: 10, ), decoration: BoxDecoration( - color: _getBackgroundColor(isSelected), + color: _getBackgroundColor(context, isSelected), borderRadius: BorderRadius.circular(12), - border: _getBorder(isSelected), + border: _getBorder(context, isSelected), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -49,7 +49,7 @@ class CategorySelector extends StatelessWidget { Icon( _getCategoryIcon(category), size: 18, - color: _getTextColor(isSelected), + color: _getTextColor(context, isSelected), ), const SizedBox(width: 6), Consumer( @@ -60,7 +60,7 @@ class CategorySelector extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: _getTextColor(isSelected), + color: _getTextColor(context, isSelected), ), ); }, @@ -100,38 +100,22 @@ class CategorySelector extends StatelessWidget { } } - Color _getBackgroundColor(bool isSelected) { - if (!isSelected) { - return isGlassmorphism - ? AppColors.backgroundColor - : Colors.grey.withValues(alpha: 0.1); - } - - if (baseColor != null) { - return baseColor!; - } - - if (gradientColors != null && gradientColors!.isNotEmpty) { - return gradientColors![0]; - } - - return const Color(0xFF3B82F6); - } - - Border? _getBorder(bool isSelected) { - if (isSelected || !isGlassmorphism) { - return null; - } - return Border.all( - color: AppColors.borderColor.withValues(alpha: 0.5), - width: 1.5, - ); - } - - Color _getTextColor(bool isSelected) { + Color _getBackgroundColor(BuildContext context, bool isSelected) { + final scheme = Theme.of(context).colorScheme; if (isSelected) { - return Colors.white; + return baseColor ?? scheme.primary; } - return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!; + return scheme.surface; + } + + Border? _getBorder(BuildContext context, bool isSelected) { + final scheme = Theme.of(context).colorScheme; + if (isSelected) return null; + return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1); + } + + Color _getTextColor(BuildContext context, bool isSelected) { + final scheme = Theme.of(context).colorScheme; + return isSelected ? scheme.onPrimary : scheme.onSurface; } } diff --git a/lib/widgets/common/form_fields/currency_dropdown_field.dart b/lib/widgets/common/form_fields/currency_dropdown_field.dart new file mode 100644 index 0000000..21f8b19 --- /dev/null +++ b/lib/widgets/common/form_fields/currency_dropdown_field.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +class CurrencyDropdownField extends StatelessWidget { + final String currency; + final ValueChanged onChanged; + + const CurrencyDropdownField({ + super.key, + required this.currency, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DropdownButtonFormField( + initialValue: currency, + isExpanded: true, + icon: const Icon(Icons.keyboard_arrow_down_rounded), + // 선택된 아이템은 코드만 간결하게 표시하여 오버플로우 방지 + selectedItemBuilder: (context) { + final color = theme.colorScheme.onSurface; + return const [ + 'KRW', + 'USD', + 'JPY', + 'CNY', + ].map((code) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + code, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, color: color), + ), + ); + }).toList(); + }, + decoration: InputDecoration( + filled: true, + fillColor: theme.colorScheme.surface, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.6), + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.colorScheme.primary, + width: 2, + ), + ), + ), + items: const [ + DropdownMenuItem( + value: 'KRW', child: _CurrencyItem(symbol: '₩', code: 'KRW')), + DropdownMenuItem( + value: 'USD', child: _CurrencyItem(symbol: '\$', code: 'USD')), + DropdownMenuItem( + value: 'JPY', child: _CurrencyItem(symbol: '¥', code: 'JPY')), + DropdownMenuItem( + value: 'CNY', child: _CurrencyItem(symbol: '¥', code: 'CNY')), + ], + onChanged: (val) { + if (val != null) onChanged(val); + }, + ); + } +} + +class _CurrencyItem extends StatelessWidget { + final String symbol; + final String code; + + const _CurrencyItem({required this.symbol, required this.code}); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.onSurface; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + symbol, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: color, + ), + ), + const SizedBox(width: 8), + Text( + code, + style: TextStyle( + fontSize: 14, + color: color, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/common/form_fields/currency_input_field.dart b/lib/widgets/common/form_fields/currency_input_field.dart index b484e14..d257d88 100644 --- a/lib/widgets/common/form_fields/currency_input_field.dart +++ b/lib/widgets/common/form_fields/currency_input_field.dart @@ -5,10 +5,10 @@ import 'base_text_field.dart'; import '../../../l10n/app_localizations.dart'; /// 통화 입력 필드 위젯 -/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다. +/// KRW/JPY(정수), USD/CNY(소수점 2자리)를 지원하며 자동 포맷팅을 제공합니다. class CurrencyInputField extends StatefulWidget { final TextEditingController controller; - final String currency; // 'KRW' or 'USD' + final String currency; // 'KRW' | 'USD' | 'JPY' | 'CNY' final String? label; final String? hintText; final Function(double?)? onChanged; @@ -39,6 +39,7 @@ class CurrencyInputField extends StatefulWidget { class _CurrencyInputFieldState extends State { late FocusNode _focusNode; bool _isFormatted = false; + bool _isPostFrameUpdating = false; @override void initState() { @@ -66,6 +67,29 @@ class _CurrencyInputFieldState extends State { super.dispose(); } + @override + void didUpdateWidget(covariant CurrencyInputField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currency != widget.currency) { + // 통화 변경 시 빌드 이후에 안전하게 재포맷 적용 + if (_focusNode.hasFocus) return; + final value = _parseValue(widget.controller.text); + if (value == null) return; + final formatted = _formatCurrency(value); + if (widget.controller.text == formatted || _isPostFrameUpdating) return; + _isPostFrameUpdating = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + _isFormatted = true; + _isPostFrameUpdating = false; + }); + } + } + void _onFocusChanged() { if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) { // 포커스를 잃었을 때 포맷팅 적용 @@ -81,7 +105,7 @@ class _CurrencyInputFieldState extends State { final value = _parseValue(widget.controller.text); if (value != null) { setState(() { - if (widget.currency == 'KRW') { + if (_isIntegerCurrency(widget.currency)) { widget.controller.text = value.toInt().toString(); } else { widget.controller.text = value.toString(); @@ -97,7 +121,7 @@ class _CurrencyInputFieldState extends State { } String _formatCurrency(double value) { - if (widget.currency == 'KRW') { + if (_isIntegerCurrency(widget.currency)) { return NumberFormat.decimalPattern().format(value.toInt()); } else { return NumberFormat('#,##0.00').format(value); @@ -108,13 +132,26 @@ class _CurrencyInputFieldState extends State { final cleanText = text .replaceAll(',', '') .replaceAll('₩', '') + .replaceAll('¥', '') + .replaceAll('¥', '') .replaceAll('\$', '') .trim(); return double.tryParse(cleanText); } + // ignore: unused_element String get _prefixText { - return widget.currency == 'KRW' ? '₩ ' : '\$ '; + switch (widget.currency) { + case 'KRW': + return '₩ '; + case 'JPY': + return '¥ '; + case 'CNY': + return '¥ '; + case 'USD': + default: + return '4 '; + } } String _getDefaultHintText(BuildContext context) { @@ -132,26 +169,27 @@ class _CurrencyInputFieldState extends State { keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow( - widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')), - if (widget.currency == 'USD') - // USD의 경우 소수점 이하 2자리까지만 허용 + _isIntegerCurrency(widget.currency) + ? RegExp(r'[0-9]') + : RegExp(r'[0-9.]'), + ), + if (!_isIntegerCurrency(widget.currency)) + // 소수 통화(USD/CNY): 소수점 이하 2자리 제한 TextInputFormatter.withFunction((oldValue, newValue) { final text = newValue.text; if (text.isEmpty) return newValue; final parts = text.split('.'); if (parts.length > 2) { - // 소수점이 2개 이상인 경우 거부 - return oldValue; + return oldValue; // 소수점이 2개 이상인 경우 거부 } if (parts.length == 2 && parts[1].length > 2) { - // 소수점 이하가 2자리를 초과하는 경우 거부 - return oldValue; + return oldValue; // 소수점 이하 2자 초과 거부 } return newValue; }), ], - prefixText: _prefixText, + prefixText: _getPrefixText(), onEditingComplete: widget.onEditingComplete, enabled: widget.enabled, onChanged: (value) { @@ -172,3 +210,23 @@ class _CurrencyInputFieldState extends State { ); } } + +bool _isIntegerCurrency(String code) => code == 'KRW' || code == 'JPY'; + +// 안전한 프리픽스 계산 함수(모든 통화 지원) +String _currencySymbol(String code) { + switch (code) { + case 'KRW': + return '₩'; + case 'JPY': + case 'CNY': + return '¥'; + case 'USD': + default: + return '\$'; + } +} + +extension on _CurrencyInputFieldState { + String _getPrefixText() => '${_currencySymbol(widget.currency)} '; +} diff --git a/lib/widgets/common/form_fields/currency_selector.dart b/lib/widgets/common/form_fields/currency_selector.dart index 84b0f01..81adf42 100644 --- a/lib/widgets/common/form_fields/currency_selector.dart +++ b/lib/widgets/common/form_fields/currency_selector.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -import '../../../theme/app_colors.dart'; +// import '../../../theme/app_colors.dart'; /// 통화 선택 위젯 /// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다. class CurrencySelector extends StatelessWidget { final String currency; final ValueChanged onChanged; - final bool isGlassmorphism; + final bool isGlassmorphism; // deprecated: ignored const CurrencySelector({ super.key, @@ -72,7 +72,7 @@ class _CurrencyOption extends StatelessWidget { final String? subtitle; final bool isSelected; final VoidCallback onTap; - final bool isGlassmorphism; + final bool isGlassmorphism; // deprecated: ignored const _CurrencyOption({ required this.label, @@ -96,7 +96,7 @@ class _CurrencyOption extends StatelessWidget { decoration: BoxDecoration( color: _getBackgroundColor(theme), borderRadius: BorderRadius.circular(12), - border: _getBorder(), + border: _getBorder(theme), ), child: Center( child: Column( @@ -107,7 +107,7 @@ class _CurrencyOption extends StatelessWidget { style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: _getTextColor(), + color: _getTextColor(theme), ), ), if (subtitle != null) ...[ @@ -117,7 +117,7 @@ class _CurrencyOption extends StatelessWidget { style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, - color: _getTextColor().withValues(alpha: 0.8), + color: _getTextColor(theme).withValues(alpha: 0.8), ), ), ], @@ -130,28 +130,20 @@ class _CurrencyOption extends StatelessWidget { } Color _getBackgroundColor(ThemeData theme) { - if (isSelected) { - return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6); - } - return isGlassmorphism - ? AppColors.surfaceColorAlt - : Colors.grey.withValues(alpha: 0.1); + final scheme = theme.colorScheme; + return isSelected ? scheme.primary : scheme.surface; } - Border? _getBorder() { - if (isSelected || !isGlassmorphism) { - return null; - } + Border? _getBorder(ThemeData theme) { + if (isSelected) return null; return Border.all( - color: AppColors.borderColor, - width: 1.5, + color: theme.colorScheme.outline.withValues(alpha: 0.6), + width: 1, ); } - Color _getTextColor() { - if (isSelected) { - return Colors.white; - } - return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!; + Color _getTextColor(ThemeData theme) { + final scheme = theme.colorScheme; + return isSelected ? scheme.onPrimary : scheme.onSurface; } } diff --git a/lib/widgets/common/form_fields/date_picker_field.dart b/lib/widgets/common/form_fields/date_picker_field.dart index b5ee04d..412db53 100644 --- a/lib/widgets/common/form_fields/date_picker_field.dart +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import '../../../theme/app_colors.dart'; +// import '../../../theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; /// 날짜 선택 필드 위젯 @@ -48,10 +48,10 @@ class DatePickerField extends StatelessWidget { children: [ Text( label, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 8), @@ -67,13 +67,14 @@ class DatePickerField extends StatelessWidget { lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)), builder: (BuildContext context, Widget? child) { + final cs = Theme.of(context).colorScheme; return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( + data: Theme.of(context).copyWith( + colorScheme: cs.copyWith( primary: effectivePrimaryColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, + onPrimary: cs.onPrimary, + surface: cs.surface, + onSurface: cs.onSurface, ), ), child: child!, @@ -90,10 +91,13 @@ class DatePickerField extends StatelessWidget { child: Container( padding: contentPadding ?? const EdgeInsets.all(16), decoration: BoxDecoration( - color: backgroundColor ?? AppColors.surfaceColorAlt, + color: backgroundColor ?? Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), border: Border.all( - color: AppColors.borderColor.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.6), width: 1.5, ), ), @@ -105,15 +109,18 @@ class DatePickerField extends StatelessWidget { .format(selectedDate), style: TextStyle( fontSize: 16, - color: - enabled ? AppColors.textPrimary : AppColors.textMuted, + color: enabled + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), Icon( Icons.calendar_today, size: 20, - color: enabled ? AppColors.navyGray : AppColors.textMuted, + color: enabled + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSurfaceVariant, ), ], ), @@ -214,13 +221,14 @@ class _DateRangeItem extends StatelessWidget { firstDate: firstDate, lastDate: lastDate, builder: (BuildContext context, Widget? child) { + final cs = Theme.of(context).colorScheme; return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( + data: Theme.of(context).copyWith( + colorScheme: cs.copyWith( primary: effectivePrimaryColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, + onPrimary: cs.onPrimary, + surface: cs.surface, + onSurface: cs.onSurface, ), ), child: child!, @@ -237,10 +245,10 @@ class _DateRangeItem extends StatelessWidget { child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), border: Border.all( - color: AppColors.borderColor.withValues(alpha: 0.7), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.6), width: 1.5, ), ), @@ -249,9 +257,9 @@ class _DateRangeItem extends StatelessWidget { children: [ Text( label, - style: const TextStyle( + style: TextStyle( fontSize: 12, - color: AppColors.textSecondary, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), @@ -263,8 +271,9 @@ class _DateRangeItem extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: - date != null ? AppColors.textPrimary : AppColors.textMuted, + color: date != null + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], diff --git a/lib/widgets/common/layout/page_container.dart b/lib/widgets/common/layout/page_container.dart new file mode 100644 index 0000000..3f590b1 --- /dev/null +++ b/lib/widgets/common/layout/page_container.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import '../../../theme/ui_constants.dart'; + +/// 페이지 공통 좌우 패딩과 최대 폭을 보장하는 래퍼 +class PageContainer extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final double maxWidth; + + const PageContainer({ + super.key, + required this.child, + this.padding, + this.maxWidth = 720, + }); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Padding( + padding: padding ?? + const EdgeInsets.symmetric( + horizontal: UIConstants.pageHorizontalPadding, + ), + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/common/snackbar/app_snackbar.dart b/lib/widgets/common/snackbar/app_snackbar.dart index 81b47e8..5ad2aba 100644 --- a/lib/widgets/common/snackbar/app_snackbar.dart +++ b/lib/widgets/common/snackbar/app_snackbar.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../../theme/app_colors.dart'; +import '../../../theme/color_scheme_ext.dart'; /// 앱 전체에서 사용되는 통합 스낵바 /// 성공, 에러, 정보 등 다양한 타입의 메시지를 표시합니다. @@ -16,9 +16,9 @@ class AppSnackBar { context: context, message: message, icon: icon, - backgroundColor: AppColors.successColor, - iconColor: AppColors.pureWhite, - textColor: AppColors.pureWhite, + backgroundColor: Theme.of(context).colorScheme.success, + iconColor: Theme.of(context).colorScheme.onPrimary, + textColor: Theme.of(context).colorScheme.onPrimary, duration: duration, showAtTop: showAtTop, ); @@ -32,13 +32,14 @@ class AppSnackBar { Duration duration = const Duration(seconds: 4), bool showAtTop = true, }) { + final cs = Theme.of(context).colorScheme; _show( context: context, message: message, icon: icon, - backgroundColor: AppColors.dangerColor, - iconColor: AppColors.pureWhite, - textColor: AppColors.pureWhite, + backgroundColor: cs.error, + iconColor: Theme.of(context).colorScheme.onPrimary, + textColor: Theme.of(context).colorScheme.onPrimary, duration: duration, showAtTop: showAtTop, ); @@ -56,9 +57,9 @@ class AppSnackBar { context: context, message: message, icon: icon, - backgroundColor: AppColors.primaryColor, - iconColor: AppColors.pureWhite, - textColor: AppColors.pureWhite, + backgroundColor: Theme.of(context).colorScheme.primary, + iconColor: Theme.of(context).colorScheme.onPrimary, + textColor: Theme.of(context).colorScheme.onPrimary, duration: duration, showAtTop: showAtTop, ); @@ -76,9 +77,9 @@ class AppSnackBar { context: context, message: message, icon: icon, - backgroundColor: AppColors.warningColor, - iconColor: AppColors.pureWhite, - textColor: AppColors.pureWhite, + backgroundColor: Theme.of(context).colorScheme.warning, + iconColor: Theme.of(context).colorScheme.onPrimary, + textColor: Theme.of(context).colorScheme.onPrimary, duration: duration, showAtTop: showAtTop, ); @@ -90,8 +91,8 @@ class AppSnackBar { required String message, required IconData icon, required Color backgroundColor, - Color iconColor = AppColors.pureWhite, - Color textColor = AppColors.pureWhite, + Color iconColor = Colors.white, + Color textColor = Colors.white, Duration duration = const Duration(seconds: 3), bool showAtTop = true, SnackBarAction? action, @@ -200,25 +201,25 @@ class AppSnackBar { width: 24, height: 24, margin: const EdgeInsets.only(right: 12), - child: const CircularProgressIndicator( + child: CircularProgressIndicator( strokeWidth: 2.5, - color: AppColors.pureWhite, + color: Theme.of(context).colorScheme.onPrimary, ), ), // 메시지 Expanded( child: Text( message, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: AppColors.pureWhite, + color: Theme.of(context).colorScheme.onPrimary, ), ), ), ], ), - backgroundColor: AppColors.primaryColor, + backgroundColor: Theme.of(context).colorScheme.primary, behavior: SnackBarBehavior.floating, margin: showAtTop ? EdgeInsets.only( @@ -249,7 +250,7 @@ class AppSnackBar { required String actionLabel, required VoidCallback onActionPressed, IconData icon = Icons.info_rounded, - Color backgroundColor = AppColors.primaryColor, + Color? backgroundColor, Duration duration = const Duration(seconds: 4), bool showAtTop = true, }) { @@ -257,14 +258,14 @@ class AppSnackBar { context: context, message: message, icon: icon, - backgroundColor: backgroundColor, - iconColor: AppColors.pureWhite, - textColor: AppColors.pureWhite, + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary, + iconColor: Theme.of(context).colorScheme.onPrimary, + textColor: Theme.of(context).colorScheme.onPrimary, duration: duration, showAtTop: showAtTop, action: SnackBarAction( label: actionLabel, - textColor: AppColors.pureWhite, + textColor: Theme.of(context).colorScheme.onPrimary, onPressed: onActionPressed, ), ); diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart index 65e360b..179d35d 100644 --- a/lib/widgets/detail/detail_event_section.dart +++ b/lib/widgets/detail/detail_event_section.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../controllers/detail_screen_controller.dart'; -import '../../theme/app_colors.dart'; +import '../../theme/color_scheme_ext.dart'; +// import '../../theme/app_colors.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; import '../../l10n/app_localizations.dart'; @@ -38,19 +39,15 @@ class DetailEventSection extends StatelessWidget { )), child: Container( decoration: BoxDecoration( - color: AppColors.glassCard, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3), width: 1, ), - boxShadow: const [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], ), child: Padding( padding: const EdgeInsets.all(24), @@ -78,10 +75,10 @@ class DetailEventSection extends StatelessWidget { const SizedBox(width: 12), Text( AppLocalizations.of(context).eventPrice, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), ], @@ -98,7 +95,8 @@ class DetailEventSection extends StatelessWidget { controller.eventPriceController.clear(); } }, - activeColor: baseColor, + activeThumbColor: baseColor, + activeTrackColor: baseColor.withValues(alpha: 0.5), ), ], ), @@ -109,27 +107,34 @@ class DetailEventSection extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.infoColor.withValues(alpha: 0.08), + color: Theme.of(context) + .colorScheme + .tertiary + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.infoColor.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .tertiary + .withValues(alpha: 0.3), width: 1, ), ), child: Row( children: [ - const Icon( + Icon( Icons.info_outline_rounded, - color: AppColors.infoColor, + color: Theme.of(context).colorScheme.tertiary, size: 20, ), const SizedBox(width: 8), Expanded( child: Text( AppLocalizations.of(context).eventPriceHint, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: AppColors.darkNavy, + color: + Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500, ), ), @@ -228,7 +233,7 @@ class _DiscountBadge extends StatelessWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), + color: Theme.of(context).colorScheme.success.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -236,15 +241,15 @@ class _DiscountBadge extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Colors.green, + color: Theme.of(context).colorScheme.success, borderRadius: BorderRadius.circular(8), ), child: Text( AppLocalizations.of(context) .discountPercent .replaceAll('@', discountPercentage.toString()), - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, fontSize: 12, fontWeight: FontWeight.w600, ), @@ -253,8 +258,8 @@ class _DiscountBadge extends StatelessWidget { const SizedBox(width: 12), Text( _getLocalizedDiscountAmount(context, currency, discountAmount), - style: const TextStyle( - color: Color(0xFF15803D), + style: TextStyle( + color: Theme.of(context).colorScheme.success, fontSize: 14, fontWeight: FontWeight.w500, ), diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart index ffaef54..fe902e6 100644 --- a/lib/widgets/detail/detail_form_section.dart +++ b/lib/widgets/detail/detail_form_section.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../controllers/detail_screen_controller.dart'; import '../../providers/category_provider.dart'; -import '../../theme/app_colors.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; -import '../common/form_fields/currency_selector.dart'; +// import '../common/form_fields/currency_selector.dart'; +import '../common/form_fields/currency_dropdown_field.dart'; import '../common/form_fields/billing_cycle_selector.dart'; import '../common/form_fields/category_selector.dart'; import '../../l10n/app_localizations.dart'; @@ -43,19 +43,15 @@ class DetailFormSection extends StatelessWidget { )), child: Container( decoration: BoxDecoration( - color: AppColors.glassCard, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3), width: 1, ), - boxShadow: const [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], ), child: Padding( padding: const EdgeInsets.all(24), @@ -100,28 +96,18 @@ class DetailFormSection extends StatelessWidget { children: [ Text( AppLocalizations.of(context).currency, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.darkNavy, + color: + Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 8), - CurrencySelector( + CurrencyDropdownField( currency: controller.currency, - isGlassmorphism: true, onChanged: (value) { controller.currency = value; - // 통화 변경시 금액 포맷 업데이트 - if (value == 'KRW') { - final amount = double.tryParse(controller - .monthlyCostController.text - .replaceAll(',', '')); - if (amount != null) { - controller.monthlyCostController.text = - amount.toInt().toString(); - } - } }, ), ], @@ -137,17 +123,16 @@ class DetailFormSection extends StatelessWidget { children: [ Text( AppLocalizations.of(context).billingCycle, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 8), BillingCycleSelector( billingCycle: controller.billingCycle, baseColor: baseColor, - isGlassmorphism: true, onChanged: (value) { controller.billingCycle = value; }, @@ -163,7 +148,9 @@ class DetailFormSection extends StatelessWidget { controller.nextBillingDate = date; }, label: AppLocalizations.of(context).nextBillingDate, - firstDate: DateTime.now(), + // 과거 결제일을 가진 항목도 수정 가능하도록 범위 완화 + firstDate: DateTime.now() + .subtract(const Duration(days: 365 * 10)), lastDate: DateTime.now().add(const Duration(days: 365 * 2)), primaryColor: baseColor, @@ -178,10 +165,10 @@ class DetailFormSection extends StatelessWidget { children: [ Text( AppLocalizations.of(context).category, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 8), @@ -189,7 +176,6 @@ class DetailFormSection extends StatelessWidget { categories: categoryProvider.categories, selectedCategoryId: controller.selectedCategoryId, baseColor: baseColor, - isGlassmorphism: true, onChanged: (categoryId) { controller.selectedCategoryId = categoryId; }, diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index ebc20c0..e716b11 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -30,11 +30,10 @@ class DetailHeaderSection extends StatelessWidget { return Consumer( builder: (context, controller, child) { final baseColor = controller.getCardColor(); - final gradient = controller.getGradient(baseColor); return Container( height: 320, - decoration: BoxDecoration(gradient: gradient), + decoration: BoxDecoration(color: baseColor), child: Stack( children: [ // 배경 패턴 diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart index 9f9c4fa..494b3ee 100644 --- a/lib/widgets/detail/detail_url_section.dart +++ b/lib/widgets/detail/detail_url_section.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import '../../controllers/detail_screen_controller.dart'; -import '../../theme/app_colors.dart'; +import '../../theme/color_scheme_ext.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/buttons/secondary_button.dart'; import '../../l10n/app_localizations.dart'; @@ -35,19 +35,13 @@ class DetailUrlSection extends StatelessWidget { )), child: Container( decoration: BoxDecoration( - color: AppColors.glassCard, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), width: 1, ), - boxShadow: const [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], ), child: Padding( padding: const EdgeInsets.all(24), @@ -72,10 +66,10 @@ class DetailUrlSection extends StatelessWidget { const SizedBox(width: 12), Text( AppLocalizations.of(context).websiteInfo, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), ], @@ -89,9 +83,9 @@ class DetailUrlSection extends StatelessWidget { label: AppLocalizations.of(context).websiteUrl, hintText: AppLocalizations.of(context).urlExample, keyboardType: TextInputType.url, - prefixIcon: const Icon( + prefixIcon: Icon( Icons.link_rounded, - color: AppColors.navyGray, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), @@ -102,10 +96,16 @@ class DetailUrlSection extends StatelessWidget { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: AppColors.warningColor.withValues(alpha: 0.08), + color: Theme.of(context) + .colorScheme + .warning + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(16), border: Border.all( - color: AppColors.warningColor.withValues(alpha: 0.4), + color: Theme.of(context) + .colorScheme + .warning + .withValues(alpha: 0.4), width: 1, ), ), @@ -114,18 +114,18 @@ class DetailUrlSection extends StatelessWidget { children: [ Row( children: [ - const Icon( + Icon( Icons.info_outline_rounded, - color: AppColors.warningColor, + color: Theme.of(context).colorScheme.warning, size: 20, ), const SizedBox(width: 8), Text( AppLocalizations.of(context).cancelGuide, - style: const TextStyle( + style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), ], @@ -133,9 +133,9 @@ class DetailUrlSection extends StatelessWidget { const SizedBox(height: 8), Text( AppLocalizations.of(context).cancelServiceGuide, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500, height: 1.5, ), @@ -145,7 +145,7 @@ class DetailUrlSection extends StatelessWidget { text: AppLocalizations.of(context).goToCancelPage, icon: Icons.open_in_new_rounded, onPressed: controller.openCancellationPage, - color: AppColors.warningColor, + color: Theme.of(context).colorScheme.warning, ), ], ), @@ -158,27 +158,33 @@ class DetailUrlSection extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.infoColor.withValues(alpha: 0.08), + color: Theme.of(context) + .colorScheme + .info + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.infoColor.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .info + .withValues(alpha: 0.3), width: 1, ), ), child: Row( children: [ - const Icon( + Icon( Icons.auto_fix_high_rounded, - color: AppColors.infoColor, + color: Theme.of(context).colorScheme.info, size: 20, ), const SizedBox(width: 8), Expanded( child: Text( AppLocalizations.of(context).urlAutoMatchInfo, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500, ), ), diff --git a/lib/widgets/dialogs/delete_confirmation_dialog.dart b/lib/widgets/dialogs/delete_confirmation_dialog.dart index 8670da6..2d6eb21 100644 --- a/lib/widgets/dialogs/delete_confirmation_dialog.dart +++ b/lib/widgets/dialogs/delete_confirmation_dialog.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'dart:ui'; -import '../../utils/reduce_motion.dart'; -import '../../theme/app_colors.dart'; +// Material 3 기반 다이얼로그 import '../common/buttons/primary_button.dart'; import '../common/buttons/secondary_button.dart'; @@ -18,148 +16,133 @@ class DeleteConfirmationDialog extends StatelessWidget { @override Widget build(BuildContext context) { return Dialog( - backgroundColor: Colors.transparent, - elevation: 0, + backgroundColor: Theme.of(context).colorScheme.surface, + elevation: 6, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Container( constraints: const BoxConstraints(maxWidth: 400), - child: Stack( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - // 글래스모피즘 배경 - ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4), - sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4), + // 아이콘 + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.error.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.delete_forever_rounded, + color: Theme.of(context).colorScheme.error, + size: 40, + ), + ), + const SizedBox(height: 24), + + // 타이틀 + Text( + '구독 삭제', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + + // 설명 + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + height: 1.5, ), - child: Container( - decoration: BoxDecoration( - color: AppColors.glassCard.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: AppColors.glassBorder, - width: 1, + children: [ + const TextSpan(text: '정말로 '), + TextSpan( + text: serviceName, + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, ), ), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 아이콘 - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.delete_forever_rounded, - color: Colors.red, - size: 40, - ), - ), - const SizedBox(height: 24), + const TextSpan(text: ' 구독을\n삭제하시겠습니까?'), + ], + ), + ), + const SizedBox(height: 8), - // 타이틀 - const Text( - '구독 삭제', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 12), - - // 설명 - RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: const TextStyle( - fontSize: 16, - color: AppColors.textSecondary, - height: 1.5, - ), - children: [ - const TextSpan(text: '정말로 '), - TextSpan( - text: serviceName, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const TextSpan(text: ' 구독을\n삭제하시겠습니까?'), - ], - ), - ), - const SizedBox(height: 8), - - // 경고 메시지 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.red.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.warning_amber_rounded, - color: Colors.red.withValues(alpha: 0.8), - size: 20, - ), - const SizedBox(width: 8), - const Text( - '이 작업은 되돌릴 수 없습니다', - style: TextStyle( - fontSize: 14, - color: Colors.red, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - const SizedBox(height: 32), - - // 버튼들 - Row( - children: [ - Expanded( - child: SecondaryButton( - text: '취소', - onPressed: () { - Navigator.of(context).pop(false); - }, - ), - ), - const SizedBox(width: 12), - Expanded( - child: PrimaryButton( - text: '삭제', - icon: Icons.delete_rounded, - onPressed: () { - Navigator.of(context).pop(true); - }, - backgroundColor: Colors.red, - ), - ), - ], - ), - ], - ), + // 경고 메시지 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.error.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.2), + width: 1, ), ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_amber_rounded, + color: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.8), + size: 20, + ), + const SizedBox(width: 8), + Text( + '이 작업은 되돌릴 수 없습니다', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 32), + + // 버튼들 + Row( + children: [ + Expanded( + child: SecondaryButton( + text: '취소', + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: PrimaryButton( + text: '삭제', + icon: Icons.delete_rounded, + onPressed: () { + Navigator.of(context).pop(true); + }, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ), + ], ), ], ), @@ -175,7 +158,7 @@ class DeleteConfirmationDialog extends StatelessWidget { final result = await showDialog( context: context, barrierDismissible: false, - barrierColor: Colors.black.withValues(alpha: 0.5), + barrierColor: Theme.of(context).colorScheme.scrim.withValues(alpha: 0.5), builder: (context) => DeleteConfirmationDialog( serviceName: serviceName, ), diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index 878c0d3..0822ade 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'dart:math' as math; -import 'glassmorphism_card.dart'; +// Glass 제거: Material 3 Card로 대체 import 'themed_text.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; import '../l10n/app_localizations.dart'; import '../utils/reduce_motion.dart'; @@ -29,106 +29,109 @@ class EmptyStateWidget extends StatelessWidget { final beginOffset = ReduceMotion.isEnabled(context) ? const Offset(0, 0.05) : const Offset(0, 0.2); + + final fade = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: fadeController, curve: Curves.easeIn), + ); + final slide = Tween(begin: beginOffset, end: Offset.zero).animate( + CurvedAnimation(parent: slideController, curve: Curves.easeOutBack), + ); + return FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), + opacity: fade, child: Center( child: SlideTransition( - position: Tween( - begin: beginOffset, - end: Offset.zero, - ).animate(CurvedAnimation( - parent: slideController, curve: Curves.easeOutBack)), + position: slide, child: RepaintBoundary( - child: GlassmorphismCard( - width: null, + child: Card( margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedBuilder( - animation: rotateController, - builder: (context, child) { - final angleScale = - ReduceMotion.isEnabled(context) ? 0.2 : 1.0; - return Transform.rotate( - angle: - angleScale * rotateController.value * 2 * math.pi, - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: AppColors.blueGradient, - begin: Alignment.topLeft, - end: Alignment.bottomRight, + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: rotateController, + builder: (context, child) { + final angleScale = + ReduceMotion.isEnabled(context) ? 0.2 : 1.0; + return Transform.rotate( + angle: + angleScale * rotateController.value * 2 * math.pi, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.subscriptions_outlined, + size: 48, + color: Theme.of(context).colorScheme.onPrimary, ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.primaryColor - .withValues(alpha: 0.3), - spreadRadius: 0, - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], ), - child: const Icon( - Icons.subscriptions_outlined, - size: 48, - color: AppColors.pureWhite, - ), - ), - ); - }, - ), - const SizedBox(height: 32), - ThemedText( - AppLocalizations.of(context).noSubscriptions, - fontSize: 22, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - ), - const SizedBox(height: 8), - ThemedText( - AppLocalizations.of(context).addSubscriptionNow, - fontSize: 16, - opacity: 0.7, - ), - const SizedBox(height: 32), - MouseRegion( - onEnter: (_) => {}, - onExit: (_) => {}, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 4, - backgroundColor: AppColors.primaryColor, - ), - onPressed: () { - HapticFeedback.mediumImpact(); - onAddPressed(); + ); }, - child: Text( - AppLocalizations.of(context).addSubscription, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: AppColors.pureWhite, + ), + const SizedBox(height: 32), + ThemedText( + AppLocalizations.of(context).noSubscriptions, + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + const SizedBox(height: 8), + ThemedText( + AppLocalizations.of(context).addSubscriptionNow, + fontSize: 16, + opacity: 0.7, + ), + const SizedBox(height: 32), + MouseRegion( + onEnter: (_) {}, + onExit: (_) {}, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.onPrimary, + backgroundColor: + Theme.of(context).colorScheme.primary, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + onPressed: () { + HapticFeedback.mediumImpact(); + onAddPressed(); + }, + child: Text( + AppLocalizations.of(context).addSubscription, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: Theme.of(context).colorScheme.onPrimary, + ), ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 326b369..0b8cb0b 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../theme/app_colors.dart'; -import 'glassmorphism_card.dart'; +// import '../theme/app_colors.dart'; import '../l10n/app_localizations.dart'; import '../utils/platform_helper.dart'; import '../utils/reduce_motion.dart'; @@ -69,11 +68,12 @@ class _FloatingNavigationBarState extends State return AnimatedBuilder( animation: _animation, builder: (context, child) { + final bottomInset = MediaQuery.of(context).padding.bottom; return Positioned( - bottom: 20, + bottom: 0, left: 16, right: 16, - height: 88, + height: 88 + bottomInset, child: Transform.translate( offset: Offset( 0, @@ -83,26 +83,15 @@ class _FloatingNavigationBarState extends State child: Opacity( opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value, child: Container( - margin: const EdgeInsets.all(4), // 그림자 공간 확보 + margin: EdgeInsets.zero, decoration: BoxDecoration( - color: AppColors.surfaceColor, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(24), - boxShadow: const [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 12, - spreadRadius: 0, - offset: Offset(0, 4), - ), - ], + boxShadow: const [], ), - child: GlassmorphismCard( + child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), - borderRadius: 24, - blur: 10.0, - backgroundColor: Colors.transparent, - boxShadow: const [], // 그림자는 Container에서 처리 child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -169,40 +158,50 @@ class _NavigationItem extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? AppColors.primaryColor.withValues(alpha: 0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - child: Icon( - icon, - color: isSelected ? AppColors.primaryColor : AppColors.navyGray, - size: isSelected ? 24 : 22, + return Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: Icon( + icon, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + size: isSelected ? 24 : 22, + ), ), - ), - const SizedBox(height: 2), - AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 200), - style: TextStyle( - fontSize: 10, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected ? AppColors.primaryColor : AppColors.navyGray, + const SizedBox(height: 2), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + child: Text(label), ), - child: Text(label), - ), - ], + ], + ), ), ), ); @@ -265,23 +264,12 @@ class _AddButtonState extends State<_AddButton> width: 56, height: 56, decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: AppColors.blueGradient, - ), + color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(16), - boxShadow: const [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 12, - offset: Offset(0, 4), - ), - ], ), - child: const Icon( + child: Icon( Icons.add_rounded, - color: AppColors.pureWhite, + color: Theme.of(context).colorScheme.onPrimary, size: 28, ), ), diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart deleted file mode 100644 index f0c2ae3..0000000 --- a/lib/widgets/glassmorphic_scaffold.dart +++ /dev/null @@ -1,316 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'dart:math' as math; -import '../theme/app_colors.dart'; -import 'floating_navigation_bar.dart'; - -/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드 -class GlassmorphicScaffold extends StatefulWidget { - final PreferredSizeWidget? appBar; - final Widget body; - final Widget? floatingActionButton; - final FloatingActionButtonLocation? floatingActionButtonLocation; - final List? backgroundGradient; - final bool extendBodyBehindAppBar; - final bool extendBody; - final Widget? bottomNavigationBar; - final bool useFloatingNavBar; - final int? floatingNavBarIndex; - final Function(int)? onFloatingNavBarTapped; - final bool resizeToAvoidBottomInset; - final Widget? drawer; - final Widget? endDrawer; - final Color? backgroundColor; - final bool enableParticles; - final bool enableWaveAnimation; - - const GlassmorphicScaffold({ - super.key, - this.appBar, - required this.body, - this.floatingActionButton, - this.floatingActionButtonLocation, - this.backgroundGradient, - this.extendBodyBehindAppBar = true, - this.extendBody = true, - this.bottomNavigationBar, - this.useFloatingNavBar = false, - this.floatingNavBarIndex, - this.onFloatingNavBarTapped, - this.resizeToAvoidBottomInset = true, - this.drawer, - this.endDrawer, - this.backgroundColor, - this.enableParticles = false, - this.enableWaveAnimation = false, - }); - - @override - State createState() => _GlassmorphicScaffoldState(); -} - -class _GlassmorphicScaffoldState extends State - with TickerProviderStateMixin { - late AnimationController _particleController; - late AnimationController _waveController; - ScrollController? _scrollController; - bool _isFloatingNavBarVisible = true; - - @override - void initState() { - super.initState(); - _particleController = AnimationController( - duration: const Duration(seconds: 20), - vsync: this, - )..repeat(); - - _waveController = AnimationController( - duration: const Duration(seconds: 10), - vsync: this, - )..repeat(); - - if (widget.useFloatingNavBar) { - _scrollController = ScrollController(); - _setupScrollListener(); - } - } - - void _setupScrollListener() { - _scrollController?.addListener(() { - final currentScroll = _scrollController!.position.pixels; - - // 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김 - if (currentScroll > 50 && - _scrollController!.position.userScrollDirection == - ScrollDirection.reverse) { - if (_isFloatingNavBarVisible) { - setState(() => _isFloatingNavBarVisible = false); - } - } else if (_scrollController!.position.userScrollDirection == - ScrollDirection.forward) { - if (!_isFloatingNavBarVisible) { - setState(() => _isFloatingNavBarVisible = true); - } - } - }); - } - - @override - void dispose() { - _particleController.dispose(); - _waveController.dispose(); - _scrollController?.dispose(); - super.dispose(); - } - - List _getBackgroundGradient() { - if (widget.backgroundGradient != null) { - return widget.backgroundGradient!; - } - - // 디폴트 그라디언트 - return AppColors.mainGradient; - } - - @override - Widget build(BuildContext context) { - final backgroundGradient = _getBackgroundGradient(); - - return Stack( - children: [ - // 배경 그라디언트 - _buildBackground(backgroundGradient), - - // 파티클 효과 (선택적) - if (widget.enableParticles) _buildParticles(), - - // 웨이브 애니메이션 (선택적) - if (widget.enableWaveAnimation) _buildWaveAnimation(), - - // 메인 스캐폴드 - Scaffold( - backgroundColor: widget.backgroundColor ?? Colors.transparent, - appBar: widget.appBar, - body: widget.body, - floatingActionButton: widget.floatingActionButton, - floatingActionButtonLocation: widget.floatingActionButtonLocation, - bottomNavigationBar: widget.bottomNavigationBar, - extendBodyBehindAppBar: widget.extendBodyBehindAppBar, - extendBody: widget.extendBody, - resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset, - drawer: widget.drawer, - endDrawer: widget.endDrawer, - ), - - // 플로팅 네비게이션 바 (선택적) - if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null) - FloatingNavigationBar( - selectedIndex: widget.floatingNavBarIndex!, - isVisible: _isFloatingNavBarVisible, - onItemTapped: widget.onFloatingNavBarTapped ?? (_) {}, - ), - ], - ); - } - - Widget _buildBackground(List gradientColors) { - return Positioned.fill( - child: Container( - color: AppColors.backgroundColor, // 베이스 색상 추가 - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: gradientColors - .map((color) => color.withValues(alpha: 0.3)) - .toList(), - ), - ), - ), - ), - ); - } - - Widget _buildParticles() { - return Positioned.fill( - child: AnimatedBuilder( - animation: _particleController, - builder: (context, child) { - final media = MediaQuery.maybeOf(context); - final reduce = media?.disableAnimations ?? false; - final count = reduce ? 10 : 30; - return CustomPaint( - painter: ParticlePainter( - animation: _particleController, - particleCount: count, - ), - ); - }, - ), - ); - } - - Widget _buildWaveAnimation() { - return Positioned( - bottom: 0, - left: 0, - right: 0, - height: 200, - child: AnimatedBuilder( - animation: _waveController, - builder: (context, child) { - return CustomPaint( - painter: WavePainter( - animation: _waveController, - waveColor: AppColors.secondaryColor.withValues(alpha: 0.1), - ), - ); - }, - ), - ); - } -} - -/// 파티클 페인터 -class ParticlePainter extends CustomPainter { - final Animation animation; - final int particleCount; - final List particles = []; - - ParticlePainter({ - required this.animation, - this.particleCount = 50, - }) : super(repaint: animation) { - _initParticles(); - } - - void _initParticles() { - final random = math.Random(); - for (int i = 0; i < particleCount; i++) { - particles.add(Particle( - x: random.nextDouble(), - y: random.nextDouble(), - size: random.nextDouble() * 3 + 1, - speed: random.nextDouble() * 0.5 + 0.1, - opacity: random.nextDouble() * 0.5 + 0.1, - )); - } - } - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint()..style = PaintingStyle.fill; - - for (final particle in particles) { - final progress = animation.value; - final y = (particle.y + progress * particle.speed) % 1.0; - - paint.color = AppColors.pureWhite.withValues(alpha: particle.opacity); - canvas.drawCircle( - Offset(particle.x * size.width, y * size.height), - particle.size, - paint, - ); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} - -/// 웨이브 페인터 -class WavePainter extends CustomPainter { - final Animation animation; - final Color waveColor; - - WavePainter({ - required this.animation, - required this.waveColor, - }) : super(repaint: animation); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = waveColor - ..style = PaintingStyle.fill; - - final path = Path(); - final progress = animation.value; - - path.moveTo(0, size.height); - - for (double x = 0; x <= size.width; x++) { - final y = - math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * - 20 + - size.height * 0.5; - path.lineTo(x, y); - } - - path.lineTo(size.width, size.height); - path.close(); - - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} - -/// 파티클 데이터 클래스 -class Particle { - final double x; - final double y; - final double size; - final double speed; - final double opacity; - - Particle({ - required this.x, - required this.y, - required this.size, - required this.speed, - required this.opacity, - }); -} diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart deleted file mode 100644 index 0b33e29..0000000 --- a/lib/widgets/glassmorphism_card.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/logger.dart'; -import 'dart:ui'; -import '../theme/app_colors.dart'; -import '../utils/reduce_motion.dart'; -import 'themed_text.dart'; - -class GlassmorphismCard extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final double? width; - final double? height; - final double borderRadius; - final double blur; - final double opacity; - final Color? backgroundColor; - final Gradient? gradient; - final Border? border; - final List? boxShadow; - final VoidCallback? onTap; - - const GlassmorphismCard({ - super.key, - required this.child, - this.padding, - this.margin, - this.width, - this.height, - this.borderRadius = 16.0, - this.blur = 10.0, - this.opacity = 0.1, - this.backgroundColor, - this.gradient, - this.border, - this.boxShadow, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Container( - width: width, - height: height, - margin: margin, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(borderRadius), - child: ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: ReduceMotion.scale(context, - normal: blur, reduced: blur * 0.4), - sigmaY: ReduceMotion.scale(context, - normal: blur, reduced: blur * 0.4), - ), - child: Container( - padding: padding, - decoration: BoxDecoration( - color: backgroundColor ?? AppColors.glassCard, - gradient: gradient ?? - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? AppColors.glassGradientDark - : AppColors.glassGradient, - ), - borderRadius: BorderRadius.circular(borderRadius), - border: border ?? - Border.all( - color: isDarkMode - ? AppColors.primaryColor.withValues(alpha: 0.3) - : AppColors.glassBorder, - width: 1, - ), - boxShadow: boxShadow ?? - [ - BoxShadow( - color: AppColors - .shadowBlack, // color.md: rgba(0,0,0,0.08) - blurRadius: ReduceMotion.scale(context, - normal: 20, reduced: 10), - spreadRadius: -5, - offset: const Offset(0, 10), - ), - ], - ), - child: GlassmorphicIndicator( - child: child, - ), - ), - ), - ), - ), - ), - ); - } -} - -// 애니메이션이 적용된 글래스모피즘 카드 -class AnimatedGlassmorphismCard extends StatefulWidget { - final Widget child; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final double? width; - final double? height; - final double borderRadius; - final double blur; - final double opacity; - final Duration animationDuration; - final VoidCallback? onTap; - - const AnimatedGlassmorphismCard({ - super.key, - required this.child, - this.padding, - this.margin, - this.width, - this.height, - this.borderRadius = 16.0, - this.blur = 10.0, - this.opacity = 0.1, - this.animationDuration = const Duration(milliseconds: 200), - this.onTap, - }); - - @override - State createState() => - _AnimatedGlassmorphismCardState(); -} - -class _AnimatedGlassmorphismCardState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnimation; - late Animation _blurAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: widget.animationDuration, - vsync: this, - ); - - _scaleAnimation = Tween( - begin: 1.0, - end: 0.98, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - )); - - _blurAnimation = Tween( - begin: widget.blur, - end: widget.blur * 1.5, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - )); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _handleTapDown(TapDownDetails details) { - _controller.forward(); - } - - void _handleTapUp(TapUpDetails details) { - _controller.reverse(); - } - - void _handleTapCancel() { - _controller.reverse(); - } - - @override - Widget build(BuildContext context) { - // onTap이 없으면 제스처 처리를 하지 않음 - if (widget.onTap == null) { - return GlassmorphismCard( - padding: widget.padding, - margin: widget.margin, - width: widget.width, - height: widget.height, - borderRadius: widget.borderRadius, - blur: widget.blur, - opacity: widget.opacity, - onTap: null, - child: widget.child, - ); - } - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: _handleTapDown, - onTapUp: (details) { - _handleTapUp(details); - // onTap 콜백 실행 - if (widget.onTap != null) { - Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행'); - widget.onTap!(); - } - }, - onTapCancel: _handleTapCancel, - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - final scaleValue = ReduceMotion.scale(context, - normal: _scaleAnimation.value, reduced: 1.0); - return Transform.scale( - scale: scaleValue, - child: GlassmorphismCard( - padding: widget.padding, - margin: widget.margin, - width: widget.width, - height: widget.height, - borderRadius: widget.borderRadius, - blur: ReduceMotion.scale(context, - normal: _blurAnimation.value, reduced: widget.blur), - opacity: widget.opacity, - onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음 - child: widget.child, - ), - ); - }, - ), - ); - } -} diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index 1ed037a..3a93cc2 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -7,7 +7,7 @@ import '../widgets/native_ad_widget.dart'; import '../widgets/main_summary_card.dart'; import '../widgets/subscription_list_widget.dart'; import '../widgets/empty_state_widget.dart'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; import '../l10n/app_localizations.dart'; class HomeContent extends StatelessWidget { @@ -35,9 +35,11 @@ class HomeContent extends StatelessWidget { final provider = context.watch(); if (provider.isLoading) { - return const Center( + return Center( child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Color(0xFF3B82F6)), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), ), ); } @@ -65,7 +67,7 @@ class HomeContent extends StatelessWidget { onRefresh: () async { await provider.refreshSubscriptions(); }, - color: const Color(0xFF3B82F6), + color: Theme.of(context).colorScheme.primary, child: CustomScrollView( controller: scrollController, physics: const BouncingScrollPhysics(), @@ -109,7 +111,7 @@ class HomeContent extends StatelessWidget { child: Text( AppLocalizations.of(context).mySubscriptions, style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.darkNavy, + color: Theme.of(context).colorScheme.onSurface, ), ), ), @@ -124,17 +126,17 @@ class HomeContent extends StatelessWidget { Text( AppLocalizations.of(context) .subscriptionCount(provider.subscriptions.length), - style: const TextStyle( + style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: AppColors.primaryColor, + color: Theme.of(context).colorScheme.primary, ), ), const SizedBox(width: 4), - const Icon( + Icon( Icons.arrow_forward_ios, size: 14, - color: AppColors.primaryColor, + color: Theme.of(context).colorScheme.primary, ), ], ), diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index b35822a..c22acae 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -4,9 +4,8 @@ import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; import '../providers/locale_provider.dart'; import '../services/currency_util.dart'; -import '../theme/app_colors.dart'; import 'animated_wave_background.dart'; -import 'glassmorphism_card.dart'; +// Glass 제거: Material 3 Card 사용 import '../l10n/app_localizations.dart'; /// 메인 화면 상단에 표시되는 요약 카드 위젯 @@ -43,20 +42,16 @@ class MainScreenSummaryCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB(16, 23, 16, 12), child: RepaintBoundary( - child: GlassmorphismCard( - borderRadius: 16, - blur: 15, - backgroundColor: AppColors.glassCard, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: AppColors.mainGradient - .map((color) => color.withValues(alpha: 0.2)) - .toList(), - ), - border: Border.all( - color: AppColors.glassBorder, - width: 1, + child: Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), ), child: Container( width: double.infinity, @@ -66,7 +61,6 @@ class MainScreenSummaryCard extends StatelessWidget { ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: Colors.transparent, ), child: ClipRRect( borderRadius: BorderRadius.circular(24), @@ -91,9 +85,9 @@ class MainScreenSummaryCard extends StatelessWidget { Text( AppLocalizations.of(context) .monthlyTotalSubscriptionCost, - style: const TextStyle( - color: AppColors - .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurface, fontSize: 15, fontWeight: FontWeight.w500, ), @@ -113,11 +107,17 @@ class MainScreenSummaryCard extends StatelessWidget { vertical: 4, ), decoration: BoxDecoration( - color: const Color(0xFFE5F2FF), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.08), borderRadius: BorderRadius.circular(4), border: Border.all( - color: const Color(0xFFBFDBFE), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), width: 1, ), ), @@ -125,10 +125,12 @@ class MainScreenSummaryCard extends StatelessWidget { AppLocalizations.of(context) .exchangeRateDisplay .replaceAll('@', snapshot.data!), - style: const TextStyle( + style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), + color: Theme.of(context) + .colorScheme + .primary, ), ), ); @@ -160,6 +162,18 @@ class MainScreenSummaryCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ + // 통화 기호를 숫자 앞에 표시 + Text( + currencySymbol, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), Text( NumberFormat.currency( locale: defaultCurrency == 'KRW' @@ -172,22 +186,15 @@ class MainScreenSummaryCard extends StatelessWidget { symbol: '', decimalDigits: decimals, ).format(monthlyCost), - style: const TextStyle( - color: AppColors.darkNavy, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface, fontSize: 32, fontWeight: FontWeight.bold, letterSpacing: -1, ), ), - const SizedBox(width: 4), - Text( - currencySymbol, - style: const TextStyle( - color: AppColors.darkNavy, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), ], ); }, @@ -248,15 +255,15 @@ class MainScreenSummaryCard extends StatelessWidget { padding: const EdgeInsets.symmetric( vertical: 10, horizontal: 14), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white.withValues(alpha: 0.2), - Colors.white.withValues(alpha: 0.15), - ], - ), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.4), borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.primaryColor + color: Theme.of(context) + .colorScheme + .outline .withValues(alpha: 0.3), width: 1, ), @@ -267,15 +274,17 @@ class MainScreenSummaryCard extends StatelessWidget { Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: - Colors.white.withValues(alpha: 0.25), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.local_offer_rounded, size: 14, - color: AppColors - .primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘 + color: + Theme.of(context).colorScheme.primary, ), ), const SizedBox(width: 10), @@ -286,9 +295,10 @@ class MainScreenSummaryCard extends StatelessWidget { Text( AppLocalizations.of(context) .eventDiscountActive, - style: const TextStyle( - color: AppColors - .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface, fontSize: 11, fontWeight: FontWeight.w500, ), @@ -328,16 +338,20 @@ class MainScreenSummaryCard extends StatelessWidget { symbol: currencySymbol, decimalDigits: decimals, ).format(eventSavings), - style: const TextStyle( - color: AppColors.primaryColor, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .primary, fontSize: 14, fontWeight: FontWeight.bold, ), ), Text( ' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})', - style: const TextStyle( - color: AppColors.navyGray, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500, ), @@ -371,7 +385,10 @@ class MainScreenSummaryCard extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), decoration: BoxDecoration( - color: AppColors.glassBackground, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -379,8 +396,8 @@ class MainScreenSummaryCard extends StatelessWidget { children: [ Text( title, - style: const TextStyle( - color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500, ), @@ -388,8 +405,8 @@ class MainScreenSummaryCard extends StatelessWidget { const SizedBox(height: 4), Text( value, - style: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 14, fontWeight: FontWeight.bold, ), diff --git a/lib/widgets/native_ad_widget.dart b/lib/widgets/native_ad_widget.dart index a1bdbd9..efd4e76 100644 --- a/lib/widgets/native_ad_widget.dart +++ b/lib/widgets/native_ad_widget.dart @@ -2,13 +2,17 @@ import 'package:flutter/material.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:io' show Platform; -import 'glassmorphism_card.dart'; +import 'dart:async'; +// Glass 제거: Material 3 Card 사용 import '../main.dart' show enableAdMob; +import '../theme/ui_constants.dart'; /// 구글 네이티브 광고 위젯 (AdMob NativeAd) /// SRP에 따라 광고 전용 위젯으로 분리 class NativeAdWidget extends StatefulWidget { - const NativeAdWidget({Key? key}) : super(key: key); + final bool useOuterPadding; // true이면 외부에서 페이지 패딩을 제공 + const NativeAdWidget({Key? key, this.useOuterPadding = false}) + : super(key: key); @override State createState() => _NativeAdWidgetState(); @@ -19,6 +23,7 @@ class _NativeAdWidgetState extends State { bool _isLoaded = false; String? _error; bool _isAdLoading = false; // 광고 로드 중복 방지 플래그 + Timer? _refreshTimer; // 주기적 리프레시 타이머 @override void initState() { @@ -43,24 +48,66 @@ class _NativeAdWidgetState extends State { return; } - _nativeAd = NativeAd( - adUnitId: _testAdUnitId(), // 실제 광고 단위 ID - factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함 - request: const AdRequest(), - listener: NativeAdListener( - onAdLoaded: (ad) { - setState(() { - _isLoaded = true; - }); - }, - onAdFailedToLoad: (ad, error) { - ad.dispose(); - setState(() { - _error = error.message; - }); - }, - ), - )..load(); + try { + // 기존 광고 해제 및 상태 초기화 + _refreshTimer?.cancel(); + _nativeAd?.dispose(); + _error = null; + _isLoaded = false; + + _nativeAd = NativeAd( + adUnitId: _testAdUnitId(), // 실제 광고 단위 ID + // 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다. + nativeTemplateStyle: NativeTemplateStyle( + templateType: TemplateType.small, + mainBackgroundColor: const Color(0x00000000), + cornerRadius: 12, + ), + request: const AdRequest(), + listener: NativeAdListener( + onAdLoaded: (ad) { + setState(() { + _isLoaded = true; + }); + _scheduleRefresh(); + }, + onAdFailedToLoad: (ad, error) { + ad.dispose(); + setState(() { + _error = error.message; + }); + // 실패 시에도 일정 시간 후 재시도 + _scheduleRefresh(); + }, + ), + )..load(); + } catch (e) { + // 템플릿 미지원 등 예외 시 광고를 비활성화하고 크래시 방지 + setState(() { + _error = e.toString(); + }); + _scheduleRefresh(); + } + } + + /// 30초 후 새 광고로 교체 + void _scheduleRefresh() { + _refreshTimer?.cancel(); + _refreshTimer = Timer(const Duration(seconds: 30), _refreshAd); + } + + void _refreshAd() { + if (!mounted) return; + // 다음 로드를 위해 상태 초기화 후 새 광고 요청 + try { + _nativeAd?.dispose(); + } catch (_) {} + setState(() { + _nativeAd = null; + _isLoaded = false; + _error = null; + }); + _loadAd(); } /// 광고 단위 ID 반환 함수 @@ -79,19 +126,25 @@ class _NativeAdWidgetState extends State { @override void dispose() { _nativeAd?.dispose(); + _refreshTimer?.cancel(); super.dispose(); } /// 웹용 광고 플레이스홀더 위젯 Widget _buildWebPlaceholder() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: GlassmorphismCard( - borderRadius: 16, - blur: 10, - opacity: 0.1, + padding: EdgeInsets.symmetric( + horizontal: + widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding, + vertical: UIConstants.adVerticalPadding, + ), + child: Card( + elevation: 1, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), child: Container( - height: 80, + height: UIConstants.adCardHeight, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ @@ -99,13 +152,16 @@ class _NativeAdWidgetState extends State { width: 64, height: 64, decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(8), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.zero, ), - child: const Center( + child: Center( child: Icon( Icons.ad_units, - color: Colors.grey, + color: Theme.of(context).colorScheme.onSurfaceVariant, size: 32, ), ), @@ -120,8 +176,11 @@ class _NativeAdWidgetState extends State { height: 14, width: 120, decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.zero, ), ), const SizedBox(height: 8), @@ -129,8 +188,11 @@ class _NativeAdWidgetState extends State { height: 10, width: 180, decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(4), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.4), + borderRadius: BorderRadius.zero, ), ), ], @@ -140,15 +202,18 @@ class _NativeAdWidgetState extends State { width: 60, height: 24, decoration: BoxDecoration( - color: Colors.blue[100], - borderRadius: BorderRadius.circular(12), + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.15), + borderRadius: BorderRadius.zero, ), - child: const Center( + child: Center( child: Text( - '광고영역', + 'ads', style: TextStyle( fontSize: 12, - color: Colors.blue, + color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, ), ), @@ -179,27 +244,29 @@ class _NativeAdWidgetState extends State { } if (_error != null) { - // 광고 로드 실패 시 빈 공간 반환 - return const SizedBox.shrink(); + // 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지 + return _buildWebPlaceholder(); } if (!_isLoaded) { - // 광고 로딩 중 로딩 인디케이터 표시 - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center(child: CircularProgressIndicator()), - ); + // 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지 + return _buildWebPlaceholder(); } // 광고 정상 노출 return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: GlassmorphismCard( - borderRadius: 16, - blur: 10, - opacity: 0.1, + padding: EdgeInsets.symmetric( + horizontal: + widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding, + vertical: UIConstants.adVerticalPadding, + ), + child: Card( + elevation: 1, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), child: SizedBox( - height: 80, // 네이티브 광고 높이 조정 + height: UIConstants.adCardHeight, child: AdWidget(ad: _nativeAd!), ), ), diff --git a/lib/widgets/sms_scan/scan_initial_widget.dart b/lib/widgets/sms_scan/scan_initial_widget.dart index 86da4b5..169ff54 100644 --- a/lib/widgets/sms_scan/scan_initial_widget.dart +++ b/lib/widgets/sms_scan/scan_initial_widget.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../theme/app_colors.dart'; +// import '../../theme/app_colors.dart'; import '../../widgets/themed_text.dart'; import '../../widgets/common/buttons/primary_button.dart'; import '../../widgets/native_ad_widget.dart'; @@ -32,7 +32,7 @@ class ScanInitialWidget extends StatelessWidget { padding: const EdgeInsets.only(bottom: 24.0), child: ThemedText( errorMessage!, - color: Colors.red, + color: Theme.of(context).colorScheme.error, textAlign: TextAlign.center, ), ), @@ -59,7 +59,7 @@ class ScanInitialWidget extends StatelessWidget { onPressed: onScanPressed, width: 200, height: 56, - backgroundColor: AppColors.primaryColor, + backgroundColor: Theme.of(context).colorScheme.primary, ), ], ), diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart index 153ecd5..7d06259 100644 --- a/lib/widgets/sms_scan/scan_loading_widget.dart +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../theme/app_colors.dart'; +// import '../../theme/app_colors.dart'; import '../../widgets/themed_text.dart'; import '../../l10n/app_localizations.dart'; @@ -14,8 +14,8 @@ class ScanLoadingWidget extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: 16), ThemedText( diff --git a/lib/widgets/sms_scan/scan_progress_widget.dart b/lib/widgets/sms_scan/scan_progress_widget.dart index e4d72b4..1f3fcc1 100644 --- a/lib/widgets/sms_scan/scan_progress_widget.dart +++ b/lib/widgets/sms_scan/scan_progress_widget.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../theme/app_colors.dart'; +// Material colors only import '../../widgets/themed_text.dart'; class ScanProgressWidget extends StatelessWidget { @@ -20,7 +20,10 @@ class ScanProgressWidget extends StatelessWidget { // 진행 상태 표시 LinearProgressIndicator( value: (currentIndex + 1) / totalCount, - backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), + backgroundColor: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.2), valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, ), diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart index d63e028..5c5158e 100644 --- a/lib/widgets/sms_scan/subscription_card_widget.dart +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -10,7 +10,6 @@ import '../../widgets/common/form_fields/base_text_field.dart'; import '../../widgets/common/form_fields/category_selector.dart'; import '../../widgets/common/snackbar/app_snackbar.dart'; import '../../widgets/native_ad_widget.dart'; -import '../../theme/app_colors.dart'; import '../../services/currency_util.dart'; import '../../utils/sms_scan/date_formatter.dart'; import '../../utils/sms_scan/category_icon_mapper.dart'; @@ -74,57 +73,50 @@ class _SubscriptionCardWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 구독 정보 카드 - ClipRRect( - borderRadius: BorderRadius.circular(16.0), - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: AppColors.glassCard, - borderRadius: BorderRadius.circular(16.0), - border: Border.all( - color: AppColors.glassBorder, - width: 1, - ), - boxShadow: const [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 20, - spreadRadius: -5, - offset: Offset(0, 10), - ), - ], + Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.4), ), - child: Column( - children: [ - // 클릭 가능한 정보 영역 - Material( - color: Colors.transparent, - child: InkWell( - onTap: _handleCardTap, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: _buildInfoSection(categoryProvider), - ), + ), + child: Column( + children: [ + // 클릭 가능한 정보 영역 + Material( + color: Colors.transparent, + child: InkWell( + onTap: _handleCardTap, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInfoSection(categoryProvider), ), ), + ), - // 구분선 - Container( - height: 1, - color: AppColors.navyGray.withValues(alpha: 0.1), - ), + // 구분선 + Container( + height: 1, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.1), + ), - // 클릭 불가능한 액션 영역 - Padding( - padding: const EdgeInsets.all(16.0), - child: _buildActionSection(categoryProvider), - ), - ], - ), + // 클릭 불가능한 액션 영역 + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildActionSection(categoryProvider), + ), + ], ), ), ], @@ -143,7 +135,6 @@ class _SubscriptionCardWidgetState extends State { AppLocalizations.of(context).foundSubscription, fontSize: 18, fontWeight: FontWeight.bold, - forceDark: true, ), const SizedBox(height: 24), @@ -152,14 +143,12 @@ class _SubscriptionCardWidgetState extends State { AppLocalizations.of(context).serviceName, fontWeight: FontWeight.w500, opacity: 0.7, - forceDark: true, ), const SizedBox(height: 4), ThemedText( widget.subscription.serviceName, fontSize: 22, fontWeight: FontWeight.bold, - forceDark: true, ), const SizedBox(height: 16), @@ -174,7 +163,6 @@ class _SubscriptionCardWidgetState extends State { AppLocalizations.of(context).monthlyCost, fontWeight: FontWeight.w500, opacity: 0.7, - forceDark: true, ), const SizedBox(height: 4), // 언어별 통화 표시 @@ -189,7 +177,6 @@ class _SubscriptionCardWidgetState extends State { snapshot.data ?? '-', fontSize: 18, fontWeight: FontWeight.bold, - forceDark: true, ); }, ), @@ -204,14 +191,12 @@ class _SubscriptionCardWidgetState extends State { AppLocalizations.of(context).billingCycle, fontWeight: FontWeight.w500, opacity: 0.7, - forceDark: true, ), const SizedBox(height: 4), ThemedText( widget.subscription.billingCycle, fontSize: 16, fontWeight: FontWeight.w500, - forceDark: true, ), ], ), @@ -225,7 +210,6 @@ class _SubscriptionCardWidgetState extends State { AppLocalizations.of(context).nextBillingDateLabel, fontWeight: FontWeight.w500, opacity: 0.7, - forceDark: true, ), const SizedBox(height: 4), ThemedText( @@ -236,7 +220,6 @@ class _SubscriptionCardWidgetState extends State { ), fontSize: 16, fontWeight: FontWeight.w500, - forceDark: true, ), ], ); @@ -252,7 +235,6 @@ class _SubscriptionCardWidgetState extends State { AppLocalizations.of(context).category, fontWeight: FontWeight.w500, opacity: 0.7, - forceDark: true, ), const SizedBox(height: 8), CategorySelector( @@ -261,7 +243,6 @@ class _SubscriptionCardWidgetState extends State { widget.selectedCategoryId ?? widget.subscription.category, onChanged: widget.onCategoryChanged, baseColor: _getCategoryColor(categoryProvider), - isGlassmorphism: true, ), const SizedBox(height: 24), @@ -270,14 +251,14 @@ class _SubscriptionCardWidgetState extends State { controller: widget.websiteUrlController, label: AppLocalizations.of(context).websiteUrlAuto, hintText: AppLocalizations.of(context).websiteUrlHint, - prefixIcon: const Icon( + prefixIcon: Icon( Icons.language, - color: AppColors.navyGray, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - style: const TextStyle( - color: AppColors.darkNavy, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, ), - fillColor: AppColors.pureWhite.withValues(alpha: 0.8), + fillColor: Theme.of(context).colorScheme.surface, ), const SizedBox(height: 32), diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index a70b832..b41093b 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -5,10 +5,12 @@ import '../providers/category_provider.dart'; import '../providers/locale_provider.dart'; import '../services/subscription_url_matcher.dart'; import '../services/currency_util.dart'; +import '../utils/billing_date_util.dart'; import 'website_icon.dart'; import 'app_navigator.dart'; -import '../theme/app_colors.dart'; -import 'glassmorphism_card.dart'; +// import '../theme/app_colors.dart'; +import '../theme/color_scheme_ext.dart'; +// import 'glassmorphism_card.dart'; import '../l10n/app_localizations.dart'; class SubscriptionCard extends StatefulWidget { @@ -30,6 +32,7 @@ class _SubscriptionCardState extends State late AnimationController _hoverController; bool _isHovering = false; String? _displayName; + static const int _nearBillingThresholdDays = 3; @override void initState() { @@ -107,6 +110,16 @@ class _SubscriptionCardState extends State // 과거 날짜인 경우, 다음 결제일 계산 final billingCycle = widget.subscription.billingCycle; + final norm = BillingDateUtil.normalizeCycle(billingCycle); + + // 분기/반기 구독 처리 + if (norm == 'quarterly' || norm == 'half-yearly') { + final nextDate = + BillingDateUtil.ensureFutureDate(nextBillingDate, billingCycle); + final days = nextDate.difference(dateOnlyNow).inDays; + if (days == 0) return AppLocalizations.of(context).paymentDueToday; + return AppLocalizations.of(context).paymentDueInDays(days); + } // 월간 구독인 경우 if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') { @@ -211,26 +224,32 @@ class _SubscriptionCardState extends State return AppLocalizations.of(context).paymentInfoNeeded; } - // 결제일이 가까운지 확인 (7일 이내) - bool _isNearBilling() { - final text = _getNextBillingText(); - if (text == AppLocalizations.of(context).paymentDueToday) return true; + int _daysUntilNextBilling() { + final now = DateTime.now(); + final dateOnlyNow = DateTime(now.year, now.month, now.day); + final nbd = widget.subscription.nextBillingDate; + final dateOnlyBilling = DateTime(nbd.year, nbd.month, nbd.day); - final regex = RegExp(r'(\d+)'); - final match = regex.firstMatch(text); - if (match != null) { - final days = int.parse(match.group(1) ?? '0'); - return days <= 7; + if (dateOnlyBilling.isAfter(dateOnlyNow)) { + return dateOnlyBilling.difference(dateOnlyNow).inDays; } - return false; + final next = + BillingDateUtil.ensureFutureDate(nbd, widget.subscription.billingCycle); + return next.difference(dateOnlyNow).inDays; + } + + // 결제일이 가까운지 확인 + bool _isNearBilling() { + final days = _daysUntilNextBilling(); + return days <= _nearBillingThresholdDays; } // 카테고리별 그라데이션 색상 생성 List _getCategoryGradientColors(BuildContext context) { try { if (widget.subscription.categoryId == null) { - return AppColors.blueGradient; + return [Theme.of(context).colorScheme.primary]; } final categoryProvider = context.watch(); @@ -238,19 +257,16 @@ class _SubscriptionCardState extends State categoryProvider.getCategoryById(widget.subscription.categoryId!); if (category == null) { - return AppColors.blueGradient; + return [Theme.of(context).colorScheme.primary]; } final categoryColor = Color(int.parse(category.color.replaceAll('#', '0xFF'))); - return [ - categoryColor, - categoryColor.withValues(alpha: 0.8), - ]; + return [categoryColor]; } catch (e) { - // 색상 파싱 실패 시 기본 파란색 그라데이션 반환 - return AppColors.blueGradient; + // 색상 파싱 실패 시 기본 primary 색 반환 + return [Theme.of(context).colorScheme.primary]; } } @@ -296,328 +312,362 @@ class _SubscriptionCardState extends State child: MouseRegion( onEnter: (_) => _onHover(true), onExit: (_) => _onHover(false), - child: AnimatedGlassmorphismCard( - padding: EdgeInsets.zero, - borderRadius: 16, - blur: _isHovering ? 15 : 10, - width: double.infinity, // 전체 너비를 차지하도록 설정 - onTap: widget.onTap ?? - () async { - // ignore: use_build_context_synchronously - await AppNavigator.toDetail(context, widget.subscription); - }, - child: Column( - children: [ - // 그라데이션 상단 바 효과 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - height: 4, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: widget.subscription.isCurrentlyInEvent - ? [ - const Color(0xFFFF6B6B), - const Color(0xFFFF8787), - ] - : isNearBilling - ? AppColors.amberGradient - : _getCategoryGradientColors(context), - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), + child: Card( + elevation: _isHovering ? 2 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.4), + width: 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: widget.onTap ?? + () async { + // ignore: use_build_context_synchronously + await AppNavigator.toDetail(context, widget.subscription); + }, + child: Column( + children: [ + // 그라데이션 상단 바 효과 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 4, + // 카테고리 우선: 상단 바는 항상 카테고리 색 + color: _getCategoryGradientColors(context).first, ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 서비스 아이콘 - WebsiteIcon( - key: ValueKey( - 'subscription_icon_${widget.subscription.id}'), - url: widget.subscription.websiteUrl, - serviceName: widget.subscription.serviceName, - size: 48, - isHovered: _isHovering, - ), - const SizedBox(width: 16), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 서비스 아이콘 + WebsiteIcon( + key: ValueKey( + 'subscription_icon_${widget.subscription.id}'), + url: widget.subscription.websiteUrl, + serviceName: widget.subscription.serviceName, + size: 48, + isHovered: _isHovering, + ), + const SizedBox(width: 16), - // 서비스 정보 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // 서비스명 - Flexible( - child: Text( - _displayName ?? - widget.subscription.serviceName, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 18, - color: AppColors - .darkNavy, // color.md 가이드: 메인 텍스트 + // 서비스 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 서비스명 + Flexible( + child: Text( + _displayName ?? + widget.subscription.serviceName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Theme.of(context) + .colorScheme + .onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), - // 배지들 - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 이벤트 배지 - if (widget - .subscription.isCurrentlyInEvent) ...[ + // 배지들 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 이벤트 배지 + if (widget + .subscription.isCurrentlyInEvent) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .error, + borderRadius: + BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.local_offer_rounded, + size: 11, + color: Theme.of(context) + .colorScheme + .onError, + ), + const SizedBox(width: 3), + Text( + AppLocalizations.of(context) + .event, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onError, + ), + ), + ], + ), + ), + const SizedBox(width: 6), + ], + + // 결제 주기 배지 Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFFFF6B6B), - Color(0xFFFF8787), - ], - ), + color: Theme.of(context) + .colorScheme + .surface, borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + width: 0.5, + ), ), - child: Row( - mainAxisSize: MainAxisSize.min, + child: Text( + AppLocalizations.of(context) + .getBillingCycleName(widget + .subscription.billingCycle), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 6), + + // 가격 정보 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 가격 표시 (이벤트 가격 반영) + // 가격 표시 (언어별 통화) + FutureBuilder( + future: _getFormattedPrice(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + + if (widget + .subscription.isCurrentlyInEvent && + snapshot.data!.contains('|')) { + final prices = snapshot.data!.split('|'); + return Row( children: [ - const Icon( - Icons.local_offer_rounded, - size: 11, - color: AppColors.pureWhite, - ), - const SizedBox(width: 3), Text( - AppLocalizations.of(context).event, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.pureWhite, + prices[0], + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + decoration: + TextDecoration.lineThrough, + ), + ), + const SizedBox(width: 8), + Text( + prices[1], + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Theme.of(context) + .colorScheme + .error, ), ), ], - ), - ), - const SizedBox(width: 6), - ], - - // 결제 주기 배지 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.borderColor, - width: 0.5, - ), - ), - child: Text( - AppLocalizations.of(context) - .getBillingCycleName( - widget.subscription.billingCycle), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors - .navyGray, // color.md 가이드: 서브 텍스트 - ), - ), - ), - ], - ), - ], - ), - - const SizedBox(height: 6), - - // 가격 정보 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // 가격 표시 (이벤트 가격 반영) - // 가격 표시 (언어별 통화) - FutureBuilder( - future: _getFormattedPrice(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - - if (widget.subscription.isCurrentlyInEvent && - snapshot.data!.contains('|')) { - final prices = snapshot.data!.split('|'); - return Row( - children: [ - Text( - prices[0], - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.navyGray, - decoration: - TextDecoration.lineThrough, - ), + ); + } else { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: widget.subscription + .isCurrentlyInEvent + ? Theme.of(context) + .colorScheme + .error + : Theme.of(context) + .colorScheme + .primary, ), - const SizedBox(width: 8), - Text( - prices[1], - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Color(0xFFFF6B6B), - ), - ), - ], - ); - } else { - return Text( - snapshot.data!, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: widget - .subscription.isCurrentlyInEvent - ? const Color(0xFFFF6B6B) - : AppColors.primaryColor, - ), - ); - } - }, - ), + ); + } + }, + ), - // 결제 예정일 정보 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: isNearBilling - ? AppColors.warningColor - .withValues(alpha: 0.1) - : AppColors.successColor - .withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isNearBilling - ? Icons.access_time_filled_rounded - : Icons.check_circle_rounded, - size: 12, - color: isNearBilling - ? AppColors.warningColor - : AppColors.successColor, - ), - const SizedBox(width: 4), - Text( - _getNextBillingText(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: isNearBilling - ? AppColors.warningColor - : AppColors.successColor, - ), - ), - ], - ), - ), - ], - ), - - // 이벤트 절약액 표시 - if (widget.subscription.isCurrentlyInEvent && - widget.subscription.eventSavings > 0) ...[ - const SizedBox(height: 4), - Row( - children: [ + // 결제 예정일 정보 Container( padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + horizontal: 8, + vertical: 3, ), decoration: BoxDecoration( - color: const Color(0xFFFF6B6B) + color: (isNearBilling + ? Theme.of(context) + .colorScheme + .warning + : Theme.of(context) + .colorScheme + .success) .withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.savings_rounded, - size: 14, - color: Color(0xFFFF6B6B), + Icon( + isNearBilling + ? Icons.access_time_filled_rounded + : Icons.check_circle_rounded, + size: 12, + color: isNearBilling + ? Theme.of(context) + .colorScheme + .warning + : Theme.of(context) + .colorScheme + .success, ), const SizedBox(width: 4), - // 이벤트 절약액 표시 (언어별 통화) - FutureBuilder( - future: CurrencyUtil - .formatEventSavingsWithLocale( - widget.subscription, - localeProvider.locale.languageCode, + Text( + _getNextBillingText(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isNearBilling + ? Theme.of(context) + .colorScheme + .warning + : Theme.of(context) + .colorScheme + .success, ), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - return Text( - '${snapshot.data!} ${AppLocalizations.of(context).saving}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFFFF6B6B), - ), - ); - }, ), ], ), ), - const SizedBox(width: 8), - // 이벤트 종료일까지 남은 일수 - if (widget.subscription.eventEndDate != - null) ...[ - Text( - AppLocalizations.of(context).daysRemaining( - widget.subscription.eventEndDate! - .difference(DateTime.now()) - .inDays), - style: const TextStyle( - fontSize: 11, - color: AppColors - .navyGray, // color.md 가이드: 서브 텍스트 - ), - ), - ], ], ), + + // 이벤트 절약액 표시 + if (widget.subscription.isCurrentlyInEvent && + widget.subscription.eventSavings > 0) ...[ + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .error + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.savings_rounded, + size: 14, + color: Theme.of(context) + .colorScheme + .error, + ), + const SizedBox(width: 4), + // 이벤트 절약액 표시 (언어별 통화) + FutureBuilder( + future: CurrencyUtil + .formatEventSavingsWithLocale( + widget.subscription, + localeProvider.locale.languageCode, + ), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return Text( + '${snapshot.data!} ${AppLocalizations.of(context).saving}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .error, + ), + ); + }, + ), + ], + ), + ), + const SizedBox(width: 8), + // 이벤트 종료일까지 남은 일수 + if (widget.subscription.eventEndDate != + null) ...[ + Text( + AppLocalizations.of(context) + .daysRemaining(widget + .subscription.eventEndDate! + .difference(DateTime.now()) + .inDays), + style: TextStyle( + fontSize: 11, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ], + ), + ], ], - ], + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/widgets/themed_text.dart b/lib/widgets/themed_text.dart index 97319dd..c3dcc74 100644 --- a/lib/widgets/themed_text.dart +++ b/lib/widgets/themed_text.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../theme/app_colors.dart'; +// Color resolution now relies on Theme ColorScheme. /// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯 class ThemedText extends StatelessWidget { @@ -40,28 +40,12 @@ class ThemedText extends StatelessWidget { bool forceLight = false, bool forceDark = false, }) { - if (forceLight) return AppColors.pureWhite; - if (forceDark) return AppColors.darkNavy; + final scheme = Theme.of(context).colorScheme; + if (forceLight) return scheme.onPrimary; // typically white + if (forceDark) return scheme.onSurface; // dark text in light theme - final brightness = Theme.of(context).brightness; - - // 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용 - if (_isGlassmorphicContext(context)) { - return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트 - } - - // 일반 환경 - return brightness == Brightness.dark - ? AppColors.pureWhite - : AppColors.darkNavy; - } - - /// 글래스모피즘 컨텍스트인지 확인 - static bool _isGlassmorphicContext(BuildContext context) { - // 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인 - final glassmorphic = - context.findAncestorWidgetOfExactType(); - return glassmorphic != null; + // 기본: 스킴의 onSurface 사용(라이트/다크 자동 대비) + return scheme.onSurface; } @override @@ -176,40 +160,3 @@ class ThemedText extends StatelessWidget { ); } } - -/// 글래스모피즘 컨텍스트를 표시하는 마커 위젯 -class GlassmorphicIndicator extends InheritedWidget { - const GlassmorphicIndicator({ - super.key, - required super.child, - }); - - static GlassmorphicIndicator? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); - } - - @override - bool updateShouldNotify(GlassmorphicIndicator oldWidget) => false; -} - -/// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼 -class GlassmorphicTextWrapper extends StatelessWidget { - final Widget child; - - const GlassmorphicTextWrapper({ - super.key, - required this.child, - }); - - @override - Widget build(BuildContext context) { - return GlassmorphicIndicator( - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith( - color: ThemedText.getContrastColor(context), - ), - child: child, - ), - ); - } -} diff --git a/lib/widgets/website_icon.dart b/lib/widgets/website_icon.dart index db8db44..9077456 100644 --- a/lib/widgets/website_icon.dart +++ b/lib/widgets/website_icon.dart @@ -5,7 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import '../theme/app_colors.dart'; +// import '../theme/app_colors.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider/path_provider.dart'; import 'package:crypto/crypto.dart'; @@ -349,11 +349,11 @@ class _WebsiteIconState extends State Color _getColorFromName() { final int hash = widget.serviceName.hashCode.abs(); final List colors = [ - AppColors.primaryColor, - AppColors.successColor, - AppColors.infoColor, - AppColors.warningColor, - AppColors.dangerColor, + const Color(0xFF2563EB), // primary + const Color(0xFF22C55E), // success + const Color(0xFF6366F1), // info + const Color(0xFFF59E0B), // warning + const Color(0xFFF472B6), // accent/danger ]; return colors[hash % colors.length]; @@ -595,10 +595,10 @@ class _WebsiteIconState extends State return Container( key: ValueKey('loading_${widget.serviceName}_$_uniqueId'), decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, + color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(widget.size * 0.2), border: Border.all( - color: AppColors.borderColor, + color: Theme.of(context).colorScheme.outline, width: 0.5, ), ), @@ -607,10 +607,10 @@ class _WebsiteIconState extends State return Container( key: ValueKey('loading_${widget.serviceName}_$_uniqueId'), decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, + color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(widget.size * 0.2), border: Border.all( - color: AppColors.borderColor, + color: Theme.of(context).colorScheme.outline, width: 0.5, ), ), @@ -621,7 +621,7 @@ class _WebsiteIconState extends State child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withValues(alpha: 0.7)), + Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)), ), ), ), @@ -661,10 +661,13 @@ class _WebsiteIconState extends State : const Duration(milliseconds: 300), placeholder: (context, url) { if (ReduceMotion.isEnabled(context)) { - return Container(color: AppColors.surfaceColorAlt); + return Container( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest); } return Container( - color: AppColors.surfaceColorAlt, + color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Center( child: SizedBox( width: widget.size * 0.4, @@ -672,7 +675,10 @@ class _WebsiteIconState extends State child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withValues(alpha: 0.7)), + Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.7)), ), ), ), @@ -712,14 +718,7 @@ class _WebsiteIconState extends State return Container( key: ValueKey('fallback_${widget.serviceName}_$_uniqueId'), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - color, - color.withValues(alpha: 0.8), // 약 0.8 알파값 - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + color: color, borderRadius: BorderRadius.circular(widget.size * 0.2), ), child: Center(