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(