feat: adopt material 3 theme and billing adjustments

This commit is contained in:
JiWoong Sul
2025-09-16 14:30:14 +09:00
parent a01d9092ba
commit 44850a53cc
85 changed files with 2957 additions and 2776 deletions

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">デジタル月額管理者</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">디지털 월세 관리자</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">数字月租管理器</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Digital Rent Manager</string>
</resources>

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false 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") include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -147,6 +147,7 @@
"estimatedAnnualCost": "Estimated Annual Cost", "estimatedAnnualCost": "Estimated Annual Cost",
"totalSubscriptionServices": "Total Subscription Services", "totalSubscriptionServices": "Total Subscription Services",
"eventDiscountActive": "Event Discount Active", "eventDiscountActive": "Event Discount Active",
"eventDiscountEndsBeforeBilling": "Event discount ends before billing date",
"saving": "Saving", "saving": "Saving",
"paymentDueToday": "Payment Due Today", "paymentDueToday": "Payment Due Today",
"paymentDueInDays": "Payment due in @ days", "paymentDueInDays": "Payment due in @ days",
@@ -199,7 +200,7 @@
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.", "cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
"goToCancelPage": "Go to Cancellation Page", "goToCancelPage": "Go to Cancellation Page",
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name", "urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
"discountPercent": "@% discount", "discountPercent": "% discount",
"discountAmountWon": "Save ₩@", "discountAmountWon": "Save ₩@",
"discountAmountDollar": "Save $@", "discountAmountDollar": "Save $@",
"discountAmountYen": "Save ¥@", "discountAmountYen": "Save ¥@",
@@ -377,6 +378,7 @@
"estimatedAnnualCost": "예상 연간 구독 비용", "estimatedAnnualCost": "예상 연간 구독 비용",
"totalSubscriptionServices": "총 구독 서비스", "totalSubscriptionServices": "총 구독 서비스",
"eventDiscountActive": "이벤트 할인 중", "eventDiscountActive": "이벤트 할인 중",
"eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다",
"saving": "절약", "saving": "절약",
"paymentDueToday": "오늘 결제 예정", "paymentDueToday": "오늘 결제 예정",
"paymentDueInDays": "@일 후 결제 예정", "paymentDueInDays": "@일 후 결제 예정",
@@ -429,7 +431,7 @@
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.", "cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
"goToCancelPage": "해지 페이지로 이동", "goToCancelPage": "해지 페이지로 이동",
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다", "urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
"discountPercent": "@% 할인", "discountPercent": "% 할인",
"discountAmountWon": "₩@원 절약", "discountAmountWon": "₩@원 절약",
"discountAmountDollar": "$@ 절약", "discountAmountDollar": "$@ 절약",
"discountAmountYen": "¥@ 절약", "discountAmountYen": "¥@ 절약",
@@ -607,6 +609,7 @@
"estimatedAnnualCost": "予想年間サブスクリプション費用", "estimatedAnnualCost": "予想年間サブスクリプション費用",
"totalSubscriptionServices": "総サブスクリプションサービス", "totalSubscriptionServices": "総サブスクリプションサービス",
"eventDiscountActive": "イベント割引中", "eventDiscountActive": "イベント割引中",
"eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します",
"saving": "節約", "saving": "節約",
"paymentDueToday": "本日支払い予定", "paymentDueToday": "本日支払い予定",
"paymentDueInDays": "@日後に支払い予定", "paymentDueInDays": "@日後に支払い予定",
@@ -659,7 +662,7 @@
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。", "cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
"goToCancelPage": "解約ページへ移動", "goToCancelPage": "解約ページへ移動",
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます", "urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
"discountPercent": "@%割引", "discountPercent": "%割引",
"discountAmountWon": "₩@節約", "discountAmountWon": "₩@節約",
"discountAmountDollar": "$@節約", "discountAmountDollar": "$@節約",
"discountAmountYen": "¥@節約", "discountAmountYen": "¥@節約",
@@ -826,6 +829,7 @@
"estimatedAnnualCost": "预计年度订阅费用", "estimatedAnnualCost": "预计年度订阅费用",
"totalSubscriptionServices": "总订阅服务", "totalSubscriptionServices": "总订阅服务",
"eventDiscountActive": "活动折扣中", "eventDiscountActive": "活动折扣中",
"eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束",
"saving": "节省", "saving": "节省",
"paymentDueToday": "今日付款到期", "paymentDueToday": "今日付款到期",
"paymentDueInDays": "@天后付款到期", "paymentDueInDays": "@天后付款到期",
@@ -878,7 +882,7 @@
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。", "cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
"goToCancelPage": "前往取消页面", "goToCancelPage": "前往取消页面",
"urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配", "urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配",
"discountPercent": "@%折扣", "discountPercent": "%折扣",
"discountAmountWon": "节省₩@", "discountAmountWon": "节省₩@",
"discountAmountDollar": "节省$@", "discountAmountDollar": "节省$@",
"discountAmountYen": "节省¥@", "discountAmountYen": "节省¥@",

View File

@@ -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 코드 | 설명/느낌 | ## 1) 원칙(신뢰·접근성·일관성)
|--------------|--------------|--------------|--------------------------| - 신뢰: Primary는 딥 블루(#2563EB). 과장된 장식 대신 명확한 위계/역할색 사용.
| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 | - 접근성: 본문 대비 WCAG AA(4.5:1) 충족. on-colors(onPrimary/onSurface/onError…) 일관 적용.
| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 | - 일관성: 전역 ColorScheme/typography/shape/elevation 우선, 로컬 styleFrom 최소화.
| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 | - 성능/가독성: Glass 제거 → 불투명 Surface + elevation/outline 중심으로 레이어 구분.
| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 |
| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 |
| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 |
| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 |
## 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(투명)) 위에는 **어두운 텍스트**를, ## 3) 타입·라디우스·간격·음영 스케일
진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다. - 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)로 수직 리듬 고정
| 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 | ## 4) Glass 제거 및 대체 규칙
|------------------|----------------------|-----------------------------------| - `lib/widgets/glassmorphism_card.dart` 사용부 전면 치환:
| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 | - 대체: `Card(elevation: 3, color: colorScheme.surface, shape: 16)`
| White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 | - 경계: `Outline` 기반(라이트 #E2E8F0, 다크 #3F3F46, 투명도 60~80%)
| Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 | - 섀도우: 라이트만 약하게(8~12), 다크는 outline 위주
| Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 | - 내부 텍스트: 항상 `colorScheme.onSurface` 또는 전역 `textTheme` 사용(하드코딩 금지)
| Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 | - 그라데이션/반투명 배경 삭제(필요 시 Hero/그림·아이콘 등으로 시각적 흥미 보완)
| Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 |
## 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) ## 6) 설정 화면에 모드 선택 UI 추가(계획)
- **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb) - 위치: `lib/screens/settings_screen.dart`
- **메인 텍스트**: Dark Navy (#1e293b) - 섹션명: Appearance(또는 테마)
- **서브/설명 텍스트**: Navy Gray (#334155) - 구성: `Theme Mode` 라디오 그룹(시스템 / 라이트 / 다크)
- **버튼 배경**: Deep Blue (#2563eb) - RadioListTile 3개(버튼 나열 유지, 드롭다운 금지)
- **버튼 텍스트**: Pure White (#ffffff) - 값: `AppThemeMode.system|light|dark`
- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6) - 동작: `context.read<ThemeProvider>().setThemeMode(mode)` 호출
- 추가 토글(유지): 큰 텍스트/모션 감소/고대비(현 Provider 연동)
## 4. 그라디언트 및 글래스 효과 예시
샘플 코드
```dart ```dart
// Flutter 예시 (Dart) final themeProvider = context.read<ThemeProvider>();
LinearGradient( Column(children: [
begin: Alignment.topLeft, ListTile(title: Text('Theme Mode')),
end: Alignment.bottomRight, RadioListTile(
colors: [ title: Text('System'),
Color(0xFF2563eb), value: AppThemeMode.system,
Color(0xFF60a5fa), groupValue: themeProvider.themeMode,
Color(0xFFe0e7ef), 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) 회귀·접근성 검증(라이트/다크/시스템)
- **텍스트 대비**를 항상 체크하세요. ## 8) 검증
밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트! - 스크립트: `scripts/check.sh` (format/analyze/test)
- **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다. - 시각: 모든 화면에서 텍스트 대비(AA) 확인, 상태(Hover/Pressed/Disabled) 점검
- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요. - 성능: Glass 제거 후 저사양 단말 스크롤/애니메이션 프레임 확인
## 6. 컬러/텍스트 조합 요약 ## 9) 요약
- Glass 제거 + 완전 Material 3 전환으로 신뢰감, 가독성, 성능을 함께 강화합니다.
- 오류색은 레드로 통일, on-colors로 대비를 보장합니다.
- 설정에 시스템/라이트/다크 선택을 제공하고, 버튼 나열 UI는 유지합니다.
| 배경색 | 텍스트색 | 용도 예시 | ## 진행 현황(Work Log)
|------------------|------------------|--------------------| - [완료] 전역 스킴 교정: `ColorScheme.error`를 레드(#EF4444)로 교정 (라이트/다크)
| #f1f5f9 | #1e293b | 메인 타이틀, 내용 | - [완료] 스낵바 오류색 정렬: `AppSnackBar.showError``colorScheme.error` 사용
| #ffffff(투명) | #2563eb | 카드 내 강조 | - [완료] 설정 화면 테마 모드 UI: System/Light/Dark SegmentedButton 추가(드롭다운/라디오 대체, M3 준수)
| #2563eb | #ffffff | 버튼, 반전 강조 | - [완료] Glass 제거(설정 화면): `GlassmorphismCard``Card` 치환
| #60a5fa | #334155 | 서브, 설명 | - [완료] Glass 제거(빈 상태 위젯): `EmptyStateWidget``Card` 기반으로 재구성
| #38bdf8 | #334155 | 포인트, 서브텍스트 | - [완료] 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).
### 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` 재실행 통과.

70
doc/plan_color.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,3 @@
/* Localized display name */
"CFBundleDisplayName" = "Digital Rent Manager";

View File

@@ -0,0 +1,3 @@
/* ローカライズされたアプリ表示名 */
"CFBundleDisplayName" = "デジタル月額管理者";

View File

@@ -0,0 +1,3 @@
/* 로컬라이즈된 앱 표시 이름 */
"CFBundleDisplayName" = "디지털 월세 관리자";

View File

@@ -0,0 +1,3 @@
/* 本地化的应用显示名称 */
"CFBundleDisplayName" = "数字月租管理器";

View File

@@ -12,6 +12,8 @@ import 'package:intl/intl.dart';
import '../widgets/dialogs/delete_confirmation_dialog.dart'; import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart';
import '../utils/business_day_util.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier { class DetailScreenController extends ChangeNotifier {
@@ -407,7 +409,14 @@ class DetailScreenController extends ChangeNotifier {
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl; subscription.websiteUrl = websiteUrl;
subscription.billingCycle = _billingCycle; 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.categoryId = _selectedCategoryId;
subscription.currency = _currency; subscription.currency = _currency;
@@ -433,6 +442,14 @@ class DetailScreenController extends ChangeNotifier {
'이벤트활성=${subscription.isEventActive}'); '이벤트활성=${subscription.isEventActive}');
// 구독 업데이트 // 구독 업데이트
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
await provider.updateSubscription(subscription); await provider.updateSubscription(subscription);
if (context.mounted) { if (context.mounted) {
@@ -575,15 +592,5 @@ class DetailScreenController extends ChangeNotifier {
return colors[hash % colors.length]; return colors[hash % colors.length];
} }
/// 그라데이션 가져오기 // getGradient 제거됨 (그라데이션 미사용)
LinearGradient getGradient(Color baseColor) {
return LinearGradient(
colors: [
baseColor,
baseColor.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
} }

View File

@@ -116,6 +116,7 @@ class AppLocalizations {
// 앱 정보 // 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version'; String get version => _localizedStrings['version'] ?? 'Version';
String get openStore => _localizedStrings['openStore'] ?? 'Open Store';
String get appDescription => String get appDescription =>
_localizedStrings['appDescription'] ?? 'Subscription Management App'; _localizedStrings['appDescription'] ?? 'Subscription Management App';
String get developer => _localizedStrings['developer'] ?? 'Developer'; String get developer => _localizedStrings['developer'] ?? 'Developer';
@@ -367,6 +368,9 @@ class AppLocalizations {
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost'; String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus => String get eventDiscountStatus =>
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get eventDiscountEndsBeforeBilling =>
_localizedStrings['eventDiscountEndsBeforeBilling'] ??
'Event discount ends before billing date';
String get inProgressUnit => String get inProgressUnit =>
_localizedStrings['inProgressUnit'] ?? 'in progress'; _localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount => String get monthlySavingAmount =>

View File

@@ -133,7 +133,9 @@ class SubManagerApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
key: ValueKey(localeProvider.locale), key: ValueKey(localeProvider.locale),
title: 'Digital Rent Manager', // Localizations는 MaterialApp 내부에서 초기화되므로
// onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다.
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: themeProvider.getTheme(context), theme: themeProvider.getTheme(context),
locale: localeProvider.locale, locale: localeProvider.locale,

View File

@@ -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_form.dart';
import '../widgets/add_subscription/add_subscription_event_section.dart'; import '../widgets/add_subscription/add_subscription_event_section.dart';
import '../widgets/add_subscription/add_subscription_save_button.dart'; import '../widgets/add_subscription/add_subscription_save_button.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
/// 새로운 구독을 추가하는 화면 /// 새로운 구독을 추가하는 화면
class AddSubscriptionScreen extends StatefulWidget { class AddSubscriptionScreen extends StatefulWidget {
@@ -45,7 +45,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
_controller.scrollController.addListener(_onScroll); _controller.scrollController.addListener(_onScroll);
return Scaffold( return Scaffold(
backgroundColor: AppColors.backgroundColor, backgroundColor: Theme.of(context).colorScheme.surface,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
appBar: AddSubscriptionAppBar( appBar: AddSubscriptionAppBar(
controller: _controller, controller: _controller,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart'; import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
class AppLockScreen extends StatelessWidget { class AppLockScreen extends StatelessWidget {
const AppLockScreen({super.key}); const AppLockScreen({super.key});
@@ -13,26 +13,26 @@ class AppLockScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( Icon(
Icons.lock_outline, Icons.lock_outline,
size: 80, size: 80,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( Text(
'앱이 잠겨 있습니다', '앱이 잠겨 있습니다',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'생체 인증으로 잠금을 해제하세요', '생체 인증으로 잠금을 해제하세요',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -41,15 +41,16 @@ class AppLockScreen extends StatelessWidget {
final appLock = context.read<AppLockProvider>(); final appLock = context.read<AppLockProvider>();
final success = await appLock.authenticate(); final success = await appLock.authenticate();
if (!success && context.mounted) { if (!success && context.mounted) {
final cs = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text( content: Text(
'인증에 실패했습니다. 다시 시도해주세요.', '인증에 실패했습니다. 다시 시도해주세요.',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: cs.onPrimary,
), ),
), ),
backgroundColor: AppColors.dangerColor, backgroundColor: cs.error,
), ),
); );
} }

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class CategoryManagementScreen extends StatefulWidget { class CategoryManagementScreen extends StatefulWidget {
@@ -43,13 +43,13 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text( title: Text(
'카테고리 관리', '카테고리 관리',
style: TextStyle( style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.pureWhite, color: Theme.of(context).colorScheme.onPrimary,
), ),
), ),
backgroundColor: AppColors.primaryColor, backgroundColor: Theme.of(context).colorScheme.primary,
), ),
body: Consumer<CategoryProvider>( body: Consumer<CategoryProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
@@ -66,10 +66,12 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '카테고리 이름', labelText: '카테고리 이름',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
), ),
validator: (value) { validator: (value) {
@@ -81,11 +83,13 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedColor, initialValue: _selectedColor,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '색상 선택', labelText: '색상 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
), ),
items: [ items: [
@@ -93,32 +97,42 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
value: '#1976D2', value: '#1976D2',
child: Text( child: Text(
AppLocalizations.of(context).colorBlue, AppLocalizations.of(context).colorBlue,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', value: '#4CAF50',
child: Text( child: Text(
AppLocalizations.of(context).colorGreen, AppLocalizations.of(context).colorGreen,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', value: '#FF9800',
child: Text( child: Text(
AppLocalizations.of(context).colorOrange, AppLocalizations.of(context).colorOrange,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', value: '#F44336',
child: Text( child: Text(
AppLocalizations.of(context).colorRed, AppLocalizations.of(context).colorRed,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', value: '#9C27B0',
child: Text( child: Text(
AppLocalizations.of(context).colorPurple, AppLocalizations.of(context).colorPurple,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -128,39 +142,51 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedIcon, initialValue: _selectedIcon,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '아이콘 선택', labelText: '아이콘 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
), ),
items: const [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'subscriptions', value: 'subscriptions',
child: Text('구독', child: Text('구독',
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'movie', value: 'movie',
child: Text('영화', child: Text('영화',
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'music_note', value: 'music_note',
child: Text('음악', child: Text('음악',
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'fitness_center', value: 'fitness_center',
child: Text('운동', child: Text('운동',
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem( DropdownMenuItem(
value: 'shopping_cart', value: 'shopping_cart',
child: Text('쇼핑', child: Text('쇼핑',
style: style: TextStyle(
TextStyle(color: AppColors.darkNavy))), color: Theme.of(context)
.colorScheme
.onSurface))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -171,12 +197,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _addCategory, onPressed: _addCategory,
child: const Text( child: const Text('카테고리 추가'),
'카테고리 추가',
style: TextStyle(
color: AppColors.pureWhite,
),
),
), ),
], ],
), ),
@@ -201,8 +222,8 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
title: Text( title: Text(
provider.getLocalizedCategoryName( provider.getLocalizedCategoryName(
context, category.name), context, category.name),
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
trailing: IconButton( trailing: IconButton(

View File

@@ -7,7 +7,7 @@ import '../widgets/detail/detail_form_section.dart';
import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_event_section.dart';
import '../widgets/detail/detail_url_section.dart'; import '../widgets/detail/detail_url_section.dart';
import '../widgets/detail/detail_action_buttons.dart'; import '../widgets/detail/detail_action_buttons.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면 /// 구독 상세 정보를 표시하고 편집할 수 있는 화면
@@ -50,7 +50,7 @@ class _DetailScreenState extends State<DetailScreen>
return ChangeNotifierProvider<DetailScreenController>.value( return ChangeNotifierProvider<DetailScreenController>.value(
value: _controller, value: _controller,
child: Scaffold( child: Scaffold(
backgroundColor: AppColors.backgroundColor, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView( body: CustomScrollView(
controller: _controller.scrollController, controller: _controller.scrollController,
slivers: [ slivers: [
@@ -77,17 +77,16 @@ class _DetailScreenState extends State<DetailScreen>
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: Theme.of(context)
colors: [ .colorScheme
baseColor.withValues(alpha: 0.15), .surfaceContainerHighest
baseColor.withValues(alpha: 0.08), .withValues(alpha: 0.4),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: baseColor.withValues(alpha: 0.2), color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
@@ -111,9 +110,9 @@ class _DetailScreenState extends State<DetailScreen>
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.changesAppliedAfterSave, .changesAppliedAfterSave,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],

View File

@@ -3,7 +3,8 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart'; import '../providers/app_lock_provider.dart';
import '../providers/navigation_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 '../routes/app_routes.dart';
import 'analysis_screen.dart'; import 'analysis_screen.dart';
import 'app_lock_screen.dart'; import 'app_lock_screen.dart';
@@ -11,7 +12,6 @@ import 'settings_screen.dart';
import 'sms_scan_screen.dart'; import 'sms_scan_screen.dart';
import '../utils/animation_controller_helper.dart'; import '../utils/animation_controller_helper.dart';
import '../widgets/floating_navigation_bar.dart'; import '../widgets/floating_navigation_bar.dart';
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/home_content.dart'; import '../widgets/home_content.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
@@ -162,33 +162,34 @@ class _MainScreenState extends State<MainScreen>
if (result == true) { if (result == true) {
// 상단에 스낵바 표시 // 상단에 스낵바 표시
if (!context.mounted) return; if (!context.mounted) return;
final cs = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: Row(
children: [ children: [
const Icon( Icon(
Icons.check_circle, Icons.check_circle,
color: AppColors.pureWhite, color: cs.onPrimary,
size: 20, size: 20,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
AppLocalizations.of(context).subscriptionAdded, AppLocalizations.of(context).subscriptionAdded,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.pureWhite, color: cs.onPrimary,
), ),
), ),
], ],
), ),
backgroundColor: AppColors.successColor, backgroundColor: cs.success,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only( margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 8, // 더 상단으로 top: MediaQuery.of(context).padding.top + 8,
left: 16, left: 16,
right: 16, right: 16,
bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로 bottom: MediaQuery.of(context).size.height - 100,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -223,8 +224,7 @@ class _MainScreenState extends State<MainScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigationProvider = context.watch<NavigationProvider>(); final navigationProvider = context.watch<NavigationProvider>();
// 메인 그라데이션 사용 // 그라데이션 제거: 단색 배경 사용
List<Color> backgroundGradient = AppColors.mainGradient;
// 현재 인덱스가 유효한지 확인 // 현재 인덱스가 유효한지 확인
int currentIndex = navigationProvider.currentIndex; int currentIndex = navigationProvider.currentIndex;
@@ -232,25 +232,31 @@ class _MainScreenState extends State<MainScreen>
currentIndex = 0; // 추가 버튼은 홈으로 표시 currentIndex = 0; // 추가 버튼은 홈으로 표시
} }
return GlassmorphicScaffold( return Stack(
body: IndexedStack( children: [
index: PlatformHelper.isIOS Positioned.fill(
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 child: Container(color: Theme.of(context).colorScheme.surface),
: (currentIndex == 3 ),
? 3 Scaffold(
: currentIndex == 4 extendBody: true,
? 4 extendBodyBehindAppBar: true,
: currentIndex), // Android: 기존 로직 body: IndexedStack(
children: _screens, index: PlatformHelper.isIOS
), ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
backgroundGradient: backgroundGradient, : (currentIndex == 3
useFloatingNavBar: true, ? 3
floatingNavBarIndex: navigationProvider.currentIndex, : currentIndex == 4
onFloatingNavBarTapped: (index) { ? 4
_handleNavigation(index, context); : currentIndex), // Android: 기존 로직
}, children: _screens,
enableParticles: false, ),
enableWaveAnimation: false, ),
FloatingNavigationBar(
selectedIndex: navigationProvider.currentIndex,
isVisible: true,
onItemTapped: (index) => _handleNavigation(index, context),
),
],
); );
} }
} }

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
import '../theme/app_colors.dart'; // Material colors only
import '../widgets/glassmorphism_card.dart'; // Glass 제거: Material 3 Card 사용
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
@@ -92,12 +92,13 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
Text( Text(
loc.smsPermissionTitle, loc.smsPermissionTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.textPrimary, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -105,24 +106,39 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
Text( Text(
loc.smsPermissionRequired, loc.smsPermissionRequired,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary), style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
GlassmorphismCard( Card(
padding: const EdgeInsets.all(16), elevation: 1,
child: Column( shape: RoundedRectangleBorder(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(12),
children: [ side: BorderSide(
Text(loc.smsPermissionReasonTitle, color: Theme.of(context)
style: const TextStyle(fontWeight: FontWeight.bold)), .colorScheme
const SizedBox(height: 8), .outline
Text(loc.smsPermissionReasonBody), .withValues(alpha: 0.5),
const SizedBox(height: 12), ),
Text(loc.smsPermissionScopeTitle, ),
style: const TextStyle(fontWeight: FontWeight.bold)), child: Padding(
const SizedBox(height: 8), padding: const EdgeInsets.all(16),
Text(loc.smsPermissionScopeBody), 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), const SizedBox(height: 24),

View File

@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
@@ -90,8 +90,6 @@ class _SplashScreenState extends State<SplashScreen>
(reduced ? 1200 : 2000); // 축소 시 더 짧게 (reduced ? 1200 : 2000); // 축소 시 더 짧게
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간 final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
int colorIndex = (random + i) % AppColors.blueGradient.length;
_particles.add({ _particles.add({
'size': size, 'size': size,
'x': x, 'x': x,
@@ -99,7 +97,7 @@ class _SplashScreenState extends State<SplashScreen>
'opacity': opacity, 'opacity': opacity,
'duration': duration, 'duration': duration,
'delay': delay, 'delay': delay,
'color': AppColors.blueGradient[colorIndex], // color computed at render from ColorScheme.primary
}); });
} }
} }
@@ -137,23 +135,15 @@ class _SplashScreenState extends State<SplashScreen>
return Scaffold( return Scaffold(
body: Stack( body: Stack(
children: [ children: [
// 배경 그라디언트 // 단색 배경
Container( Container(color: Theme.of(context).colorScheme.surface),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.dayGradient[0],
AppColors.dayGradient[1],
],
),
),
),
// 글래스모피즘 오버레이 // 글래스모피즘 오버레이
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite.withValues(alpha: 0.05), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
), ),
), ),
Stack( Stack(
@@ -180,11 +170,14 @@ class _SplashScreenState extends State<SplashScreen>
width: particle['size'], width: particle['size'],
height: particle['size'], height: particle['size'],
decoration: BoxDecoration( decoration: BoxDecoration(
color: particle['color'], color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: particle['color'].withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
blurRadius: 10, blurRadius: 10,
spreadRadius: 1, spreadRadius: 1,
), ),
@@ -195,43 +188,23 @@ class _SplashScreenState extends State<SplashScreen>
); );
}).toList(), }).toList(),
// 상단 원형 그라데이션 // 상단 원형 장식 제거(단색 배경 유지)
Positioned( Positioned(
top: -size.height * 0.2, top: -size.height * 0.2,
right: -size.width * 0.2, right: -size.width * 0.2,
child: Container( child: SizedBox(
width: size.width * 0.8, width: size.width * 0.8,
height: 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( Positioned(
bottom: -size.height * 0.1, bottom: -size.height * 0.1,
left: -size.width * 0.3, left: -size.width * 0.3,
child: Container( child: SizedBox(
width: size.width * 0.9, width: size.width * 0.9,
height: 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<SplashScreen>
reduced: 8)), reduced: 8)),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: Theme.of(context)
begin: Alignment.topLeft, .colorScheme
end: Alignment.bottomRight, .surface
colors: [ .withValues(alpha: 0.6),
AppColors.pureWhite
.withValues(alpha: 0.2),
AppColors.pureWhite
.withValues(alpha: 0.1),
],
),
borderRadius: borderRadius:
BorderRadius.circular(30), BorderRadius.circular(30),
border: Border.all( border: Border.all(
color: AppColors.pureWhite color: Theme.of(context)
.withValues(alpha: 0.3), .colorScheme
.outline
.withValues(alpha: 0.2),
width: 1.5, 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: Center(
child: AnimatedBuilder( child: AnimatedBuilder(
animation: animation:
_animationController, _animationController,
builder: (context, _) { builder: (context, _) {
return ShaderMask( return Icon(
blendMode: Icons
BlendMode.srcIn, .subscriptions_outlined,
shaderCallback: (bounds) => size: 64,
const LinearGradient( color: Theme.of(context)
colors: AppColors .colorScheme
.blueGradient, .primary,
begin:
Alignment.topLeft,
end: Alignment
.bottomRight,
).createShader(bounds),
child: Icon(
Icons
.subscriptions_outlined,
size: 64,
color:
Theme.of(context)
.primaryColor,
),
); );
}), }),
), ),
@@ -356,7 +299,9 @@ class _SplashScreenState extends State<SplashScreen>
style: TextStyle( style: TextStyle(
fontSize: 36, fontSize: 36,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.primaryColor color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.9), .withValues(alpha: 0.9),
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
@@ -382,7 +327,9 @@ class _SplashScreenState extends State<SplashScreen>
AppLocalizations.of(context).appSubtitle, AppLocalizations.of(context).appSubtitle,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.primaryColor color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7), .withValues(alpha: 0.7),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
@@ -404,18 +351,22 @@ class _SplashScreenState extends State<SplashScreen>
height: 60, height: 60,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
border: Border.all( border: Border.all(
color: AppColors.pureWhite color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
width: 1, width: 1,
), ),
), ),
child: const CircularProgressIndicator( child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( color:
AppColors.pureWhite), Theme.of(context).colorScheme.primary,
strokeWidth: 3, strokeWidth: 3,
), ),
), ),
@@ -436,7 +387,10 @@ class _SplashScreenState extends State<SplashScreen>
'© 2025 NatureBridgeAI. All rights reserved.', '© 2025 NatureBridgeAI. All rights reserved.',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.pureWhite.withValues(alpha: 0.6), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),

View File

@@ -5,6 +5,7 @@ import '../utils/logger.dart';
import '../temp/test_sms_data.dart'; import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
class SmsScanner { class SmsScanner {
final SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
@@ -56,7 +57,61 @@ class SmsScanner {
// 2회 이상 반복된 서비스만 구독으로 간주 // 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 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<String, dynamic>.from(mostRecent);
serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String();
final subscription = _parseSms(serviceSms, entry.value.length); final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) { if (subscription != null) {
Log.i( Log.i(
@@ -134,7 +189,11 @@ class SmsScanner {
} }
DateTime? nextBillingDate; 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); nextBillingDate = DateTime.tryParse(nextBillingDateStr);
} }
@@ -146,8 +205,12 @@ class SmsScanner {
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정 // 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
DateTime adjustedNextBillingDate = _calculateNextBillingDate( DateTime adjustedNextBillingDate = _calculateNextBillingDate(
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle); billingCycle,
);
// 주말/공휴일 보정
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel( return SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(), 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') { } else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인 // 올해의 결제일이 지났는지 확인
final thisYearBilling = final thisYearBilling =

View File

@@ -10,114 +10,119 @@ class AdaptiveTheme {
/// 다크 테마 /// 다크 테마
static ThemeData get darkTheme { 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( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: const ColorScheme.dark( colorScheme: scheme,
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
surface: Color(0xFF1E1E1E),
),
scaffoldBackgroundColor: const Color(0xFF121212), scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: const Color(0xFF1E1E1E), color: scheme.surface,
elevation: 2, elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide( 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, clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: scheme.surface,
foregroundColor: Colors.white, foregroundColor: scheme.onSurface,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: const TextStyle( // title/icon colors inherit from foregroundColor
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,
),
), ),
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( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: const Color(0xFF2A2A2A), fillColor: scheme.surface,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16), const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -126,33 +131,31 @@ class AdaptiveTheme {
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: borderSide: BorderSide(color: scheme.outline, width: 1),
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: borderSide: BorderSide(color: scheme.primary, width: 1.5),
const BorderSide(color: AppColors.primaryColor, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), borderSide: BorderSide(color: scheme.error, width: 1),
), ),
labelStyle: TextStyle( labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
hintStyle: TextStyle( hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor, backgroundColor: scheme.primary,
foregroundColor: Colors.white, foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48), minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -161,8 +164,66 @@ class AdaptiveTheme {
elevation: 0, elevation: 0,
), ),
), ),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
trackColor: WidgetStateProperty.resolveWith<Color>((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<Color>((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<Color>((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( dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1), color: scheme.outline,
thickness: 1, thickness: 1,
space: 16, space: 16,
), ),
@@ -171,19 +232,15 @@ class AdaptiveTheme {
/// OLED 최적화 다크 테마 /// OLED 최적화 다크 테마
static ThemeData get oledTheme { static ThemeData get oledTheme {
return darkTheme.copyWith( final base = darkTheme;
const oledSurface = Color(0xFF0A0A0A);
return base.copyWith(
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith( colorScheme: base.colorScheme.copyWith(surface: oledSurface),
surface: const Color(0xFF0A0A0A), cardTheme: base.cardTheme.copyWith(color: oledSurface),
), appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
cardTheme: darkTheme.cardTheme.copyWith( inputDecorationTheme: base.inputDecorationTheme.copyWith(
color: const Color(0xFF0A0A0A), fillColor: oledSurface,
),
appBarTheme: darkTheme.appBarTheme.copyWith(
backgroundColor: Colors.black,
),
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
fillColor: const Color(0xFF0A0A0A),
), ),
); );
} }

View File

@@ -7,7 +7,8 @@ class AppColors {
static const successColor = Color(0xFF38BDF8); // 소프트 민트 static const successColor = Color(0xFF38BDF8); // 소프트 민트
static const infoColor = Color(0xFF6366F1); // 인디고 static const infoColor = Color(0xFF6366F1); // 인디고
static const warningColor = Color(0xFFF59E0B); // 앰버 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 static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
@@ -31,27 +32,7 @@ class AppColors {
// 그림자 (color.md 가이드) // 그림자 (color.md 가이드)
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
// 그라데이션 컬러 - 다양한 효과를 위한 조합 // (그라데이션 컬러 제거됨)
static const List<Color> blueGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA) // 스카이 블루
];
static const List<Color> tealGradient = [
Color(0xFF14B8A6),
Color(0xFF0D9488)
];
static const List<Color> purpleGradient = [
Color(0xFF8B5CF6),
Color(0xFF7C3AED)
];
static const List<Color> amberGradient = [
Color(0xFFF59E0B),
Color(0xFFD97706)
];
static const List<Color> roseGradient = [
Color(0xFFF43F5E),
Color(0xFFE11D48)
];
// Glassmorphism 효과를 위한 색상 // Glassmorphism 효과를 위한 색상
static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity) static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
@@ -66,47 +47,9 @@ class AppColors {
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity) static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity) static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
// 백드롭 블러 효과를 위한 그라디언트 // (백드롭 블러 그라데이션 제거됨)
static const List<Color> glassGradient = [
Color(0x33FFFFFF), // 20% white
Color(0x1AFFFFFF), // 10% white
];
static const List<Color> glassGradientDark = [ // (메인/액센트 그라데이션 제거됨)
Color(0x1A000000), // 10% black
Color(0x0F000000), // 6% black
];
// 메인 그라데이션 // (시간대별 배경 그라데이션 제거됨)
static const List<Color> mainGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA), // 스카이 블루
Color(0xFFE0E7EF), // 라이트 그레이
];
static const List<Color> accentGradient = [
Color(0xFF38BDF8), // 소프트 민트
Color(0xFF60A5FA), // 스카이 블루
];
// 시간대별 배경 그라디언트
static const List<Color> morningGradient = [
Color(0xFFFED7AA), // 따뜻한 오렌지
Color(0xFFFBBF24), // 부드러운 노랑
];
static const List<Color> dayGradient = [
Color(0xFFDDEAFC), // 연한 하늘색
Color(0xFFBFDBFE), // 맑은 파랑
];
static const List<Color> eveningGradient = [
Color(0xFFFCA5A5), // 부드러운 핑크
Color(0xFFC084FC), // 연한 보라
];
static const List<Color> nightGradient = [
Color(0xFF4338CA), // 깊은 인디고
Color(0xFF1E1B4B), // 다크 네이비
];
} }

View File

@@ -2,354 +2,320 @@ import 'package:flutter/material.dart';
import 'app_colors.dart'; import 'app_colors.dart';
class AppTheme { class AppTheme {
static ThemeData lightTheme = ThemeData( static ThemeData lightTheme = (() {
useMaterial3: true, // Color scheme for light theme
colorScheme: const ColorScheme.light( const scheme = ColorScheme.light(
primary: AppColors.primaryColor, primary: AppColors.primaryColor,
onPrimary: Colors.white, onPrimary: Colors.white,
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.errorColor,
surface: AppColors.surfaceColor, surface: AppColors.surfaceColor,
), );
// 기본 배경색 return ThemeData(
scaffoldBackgroundColor: AppColors.backgroundColor, useMaterial3: true,
colorScheme: scheme,
// 카드 스타일 - 글래스모피즘 효과 // 기본 배경색
cardTheme: CardThemeData( scaffoldBackgroundColor: AppColors.backgroundColor,
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),
),
// 앱바 스타일 - 글래스모피즘 디자인 // 카드 스타일 - Material 3 표면 중심
appBarTheme: const AppBarTheme( cardTheme: CardThemeData(
backgroundColor: Colors.transparent, elevation: 1,
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),
shape: RoundedRectangleBorder( 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, 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, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
), ),
),
// 텍스트 버튼 스타일 // 스위치 스타일 (공통 테마)
textButtonTheme: TextButtonThemeData( switchTheme: SwitchThemeData(
style: TextButton.styleFrom( thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
foregroundColor: AppColors.primaryColor, if (states.contains(WidgetState.selected)) {
minimumSize: const Size(0, 40), return scheme.primary;
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), }
return scheme.onSurfaceVariant; // OFF 썸을 명확하게
}),
trackColor: WidgetStateProperty.resolveWith<Color>((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<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder( 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<Color>((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, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
), unselectedLabelStyle: const TextStyle(
), fontSize: 14,
fontWeight: FontWeight.w500,
// 아웃라인 버튼 스타일
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,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
), ),
),
// FAB 스타일 // 디바이더 스타일
floatingActionButtonTheme: FloatingActionButtonThemeData( dividerTheme: DividerThemeData(
backgroundColor: AppColors.primaryColor, color: scheme.outline,
foregroundColor: Colors.white, thickness: 1,
shape: RoundedRectangleBorder( space: 16,
borderRadius: BorderRadius.circular(16),
), ),
elevation: 2,
extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), // 페이지 트랜지션
extendedTextStyle: const TextStyle( pageTransitionsTheme: const PageTransitionsTheme(
fontSize: 15, builders: {
fontWeight: FontWeight.w600, TargetPlatform.android: ZoomPageTransitionsBuilder(),
letterSpacing: 0.1, TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
},
), ),
),
// 스위치 스타일 // 스낵바 스타일 (기본 유지)
switchTheme: SwitchThemeData( snackBarTheme: SnackBarThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) { backgroundColor: scheme.primary,
if (states.contains(WidgetState.selected)) { contentTextStyle: TextStyle(
return AppColors.primaryColor; color: scheme.onPrimary,
} fontSize: 14,
return Colors.white; fontWeight: FontWeight.w500,
}), ),
trackColor: WidgetStateProperty.resolveWith<Color>((states) { shape: RoundedRectangleBorder(
if (states.contains(WidgetState.selected)) { borderRadius: BorderRadius.circular(8),
return AppColors.secondaryColor.withValues(alpha: 0.5); ),
} behavior: SnackBarBehavior.floating,
return AppColors.borderColor;
}),
),
// 체크박스 스타일
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
), ),
side: const BorderSide(color: AppColors.secondaryColor, width: 1.5), );
), })();
// 라디오 버튼 스타일
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((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,
),
);
} }

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -32,17 +32,9 @@ class AnimationControllerHelper {
pulseController.duration = const Duration(milliseconds: 1500); pulseController.duration = const Duration(milliseconds: 1500);
pulseController.repeat(reverse: true); pulseController.repeat(reverse: true);
// 웨이브 컨트롤러 초기화 // 웨이브 컨트롤러 초기화: 반복으로 부드럽게 루프
waveController.duration = const Duration(milliseconds: 8000); waveController.duration = const Duration(milliseconds: 8000);
waveController.forward(); waveController.repeat();
// 웨이브 애니메이션이 끝나면 다시 처음부터 부드럽게 시작하도록 설정
waveController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
waveController.reset();
waveController.forward();
}
});
} }
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드 /// 모든 애니메이션 컨트롤러를 재설정하는 메서드

View File

@@ -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);
}
}

View File

@@ -0,0 +1,39 @@
/// 영업일 계산 유틸리티
/// - 주말(토/일)과 일부 고정 공휴일을 제외하고 다음 영업일을 계산합니다.
/// - 음력 기반 공휴일(설/추석 등)은 포함하지 않습니다. 필요 시 외부 소스 연동을 고려하세요.
class BusinessDayUtil {
static bool isWeekend(DateTime date) =>
date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
/// 고정일 한국 공휴일(대체공휴일 미포함)
static const List<String> _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;
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb; // import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; // import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../../controllers/add_subscription_controller.dart'; import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -26,9 +26,11 @@ class AddSubscriptionAppBar extends StatelessWidget
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100)); final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100));
final scheme = Theme.of(context).colorScheme;
return Container( return Container(
decoration: BoxDecoration( 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: appBarOpacity > 0.6
? [ ? [
BoxShadow( BoxShadow(
@@ -43,10 +45,10 @@ class AddSubscriptionAppBar extends StatelessWidget
child: SafeArea( child: SafeArea(
child: AppBar( child: AppBar(
leading: IconButton( leading: IconButton(
icon: const Icon( icon: Icon(
Icons.chevron_left, Icons.chevron_left,
size: 28, size: 28,
color: Color(0xFF1E293B), color: Theme.of(context).colorScheme.onSurface,
), ),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
@@ -57,7 +59,7 @@ class AddSubscriptionAppBar extends StatelessWidget
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: -0.5, letterSpacing: -0.5,
color: const Color(0xFF1E293B), color: Theme.of(context).colorScheme.onSurface,
shadows: appBarOpacity > 0.6 shadows: appBarOpacity > 0.6
? [ ? [
Shadow( Shadow(
@@ -71,33 +73,8 @@ class AddSubscriptionAppBar extends StatelessWidget
), ),
elevation: 0, elevation: 0,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
actions: [ // SMS 스캔 버튼 제거: 우측 액션 비움
if (!kIsWeb) actions: const [],
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>(
Color(0xFF3B82F6)),
),
),
),
)
: IconButton(
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
color: Color(0xFF3B82F6),
),
onPressed: onScanSMS,
tooltip: AppLocalizations.of(context).scanTextMessages,
),
],
), ),
), ),
); );

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart'; import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
/// 구독 추가 화면의 이벤트/할인 섹션 /// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget { class AddSubscriptionEventSection extends StatelessWidget {
@@ -40,19 +40,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassCard, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1), color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -95,10 +89,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
} }
return Text( return Text(
titleText, titleText,
style: const TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, 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( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08), color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: Theme.of(context).colorScheme.tertiary,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -174,9 +176,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
} }
return Text( return Text(
infoText, infoText,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: Theme.of(context)
.colorScheme
.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
); );
@@ -272,6 +276,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
currency: controller.currency, currency: controller.currency,
label: eventPriceLabel, label: eventPriceLabel,
hintText: eventPriceHint, hintText: eventPriceHint,
enabled: controller.isEventActive,
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
validator:
controller.isEventActive ? null : (_) => null,
); );
}, },
), ),

View File

@@ -7,11 +7,11 @@ import '../../l10n/app_localizations.dart';
import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_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/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart'; import '../common/form_fields/category_selector.dart';
import '../glassmorphism_card.dart'; // Glass 제거: Material 3 Card 사용
import '../../theme/app_colors.dart'; // Material colors only
/// 구독 추가 화면의 폼 섹션 /// 구독 추가 화면의 폼 섹션
class AddSubscriptionForm extends StatelessWidget { class AddSubscriptionForm extends StatelessWidget {
@@ -45,8 +45,15 @@ class AddSubscriptionForm extends StatelessWidget {
parent: controller.animationController!, parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)), )),
child: GlassmorphismCard( child: Card(
backgroundColor: AppColors.glassCard, elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@@ -55,26 +62,19 @@ class AddSubscriptionForm extends StatelessWidget {
// 헤더 // 헤더
Row( Row(
children: [ children: [
ShaderMask( Icon(
shaderCallback: (bounds) => LinearGradient( FontAwesomeIcons.fileLines,
colors: controller.gradientColors, size: 20,
begin: Alignment.topLeft, color: Theme.of(context).colorScheme.primary,
end: Alignment.bottomRight,
).createShader(bounds),
child: const Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Colors.white,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
AppLocalizations.of(context).serviceInfo, AppLocalizations.of(context).serviceInfo,
style: const TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
color: Color(0xFF1E293B), color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],
@@ -136,9 +136,8 @@ class AddSubscriptionForm extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
CurrencySelector( CurrencyDropdownField(
currency: controller.currency, currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
controller.currency = value; controller.currency = value;
@@ -158,8 +157,8 @@ class AddSubscriptionForm extends StatelessWidget {
children: [ children: [
Text( Text(
AppLocalizations.of(context).billingCycle, AppLocalizations.of(context).billingCycle,
style: const TextStyle( style: TextStyle(
color: AppColors.textPrimary, color: Theme.of(context).colorScheme.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -168,7 +167,6 @@ class AddSubscriptionForm extends StatelessWidget {
BillingCycleSelector( BillingCycleSelector(
billingCycle: controller.billingCycle, billingCycle: controller.billingCycle,
baseColor: controller.gradientColors[0], baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
controller.billingCycle = value; controller.billingCycle = value;
@@ -203,7 +201,7 @@ class AddSubscriptionForm extends StatelessWidget {
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: Icon( prefixIcon: Icon(
Icons.link_rounded, Icons.link_rounded,
color: Colors.grey[600], color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -226,7 +224,6 @@ class AddSubscriptionForm extends StatelessWidget {
categories: categoryProvider.categories, categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId, selectedCategoryId: controller.selectedCategoryId,
baseColor: controller.gradientColors[0], baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (categoryId) { onChanged: (categoryId) {
setState(() { setState(() {
controller.selectedCategoryId = categoryId; controller.selectedCategoryId = categoryId;

View File

@@ -26,19 +26,7 @@ class AddSubscriptionHeader extends StatelessWidget {
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
gradient: LinearGradient( color: Theme.of(context).colorScheme.primary,
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),
),
],
), ),
child: Row( child: Row(
children: [ children: [
@@ -48,10 +36,10 @@ class AddSubscriptionHeader extends StatelessWidget {
color: Colors.white.withValues(alpha: 0.2), color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: const Icon( child: Icon(
Icons.add_rounded, Icons.add_rounded,
size: 32, size: 32,
color: Colors.white, color: Theme.of(context).colorScheme.onPrimary,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -61,20 +49,23 @@ class AddSubscriptionHeader extends StatelessWidget {
children: [ children: [
Text( Text(
AppLocalizations.of(context).newSubscriptionAdd, AppLocalizations.of(context).newSubscriptionAdd,
style: const TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white, color: Theme.of(context).colorScheme.onPrimary,
letterSpacing: -0.5, letterSpacing: -0.5,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
AppLocalizations.of(context).enterServiceInfo, AppLocalizations.of(context).enterServiceInfo,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.white70, color: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.7),
), ),
), ),
], ],

View File

@@ -44,7 +44,7 @@ class AddSubscriptionSaveButton extends StatelessWidget {
? null ? null
: () => controller.saveSubscription(setState: setState), : () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading, isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6), backgroundColor: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),

View File

@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget { class AnalysisBadge extends StatelessWidget {
@@ -26,15 +26,15 @@ class AnalysisBadge extends StatelessWidget {
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite, color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: borderColor, color: borderColor,
width: 2, width: 2,
), ),
boxShadow: const [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10, blurRadius: 10,
spreadRadius: 2, spreadRadius: 2,
), ),
@@ -48,10 +48,10 @@ class AnalysisBadge extends StatelessWidget {
subscription.serviceName.length > 5 subscription.serviceName.length > 5
? '${subscription.serviceName.substring(0, 5)}...' ? '${subscription.serviceName.substring(0, 5)}...'
: subscription.serviceName, : subscription.serviceName,
style: const TextStyle( style: TextStyle(
fontSize: 8, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 0), const SizedBox(height: 0),
@@ -82,9 +82,9 @@ class AnalysisBadge extends StatelessWidget {
} }
return Text( return Text(
displayText, displayText,
style: const TextStyle( style: TextStyle(
fontSize: 7, fontSize: 7,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
); );
} }

View File

@@ -3,10 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart'; import '../../providers/subscription_provider.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../theme/app_colors.dart'; // Glass 제거: Material 3 Card 사용
import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯 /// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget { class EventAnalysisCard extends StatelessWidget {
@@ -38,10 +38,17 @@ class EventAnalysisCard extends StatelessWidget {
parent: animationController, parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut), curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)), )),
child: GlassmorphismCard( child: Card(
blur: 10, elevation: 3,
opacity: 0.1, shape: RoundedRectangleBorder(
borderRadius: 16, borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -64,20 +71,18 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color:
colors: [ Theme.of(context).colorScheme.error,
Color(0xFFFF6B6B),
Color(0xFFFE7E7E),
],
),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
children: [ children: [
const FaIcon( FaIcon(
FontAwesomeIcons.fire, FontAwesomeIcons.fire,
size: 12, size: 12,
color: AppColors.pureWhite, color: Theme.of(context)
.colorScheme
.onError,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -85,10 +90,12 @@ class EventAnalysisCard extends StatelessWidget {
.servicesInProgress(provider .servicesInProgress(provider
.activeEventSubscriptions .activeEventSubscriptions
.length), .length),
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: Theme.of(context)
.colorScheme
.onError,
), ),
), ),
], ],
@@ -100,27 +107,24 @@ class EventAnalysisCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: Theme.of(context)
colors: [ .colorScheme
const Color(0xFFFF6B6B) .error
.withValues(alpha: 0.1), .withValues(alpha: 0.08),
const Color(0xFFFF8787)
.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: const Color(0xFFFF6B6B) color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
), ),
), ),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.savings, Icons.savings,
color: Color(0xFFFF6B6B), color:
Theme.of(context).colorScheme.error,
size: 32, size: 32,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -142,10 +146,12 @@ class EventAnalysisCard extends StatelessWidget {
CurrencyUtil.formatTotalAmount( CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(), provider.calculateTotalSavings(),
), ),
style: const TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, 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), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.darkNavy color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05), .withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.darkNavy color: Theme.of(context)
.withValues(alpha: 0.1), .colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
), ),
), ),
child: Row( child: Row(
@@ -207,13 +217,15 @@ class EventAnalysisCard extends StatelessWidget {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
snapshot.data!, snapshot.data!,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
decoration: decoration:
TextDecoration TextDecoration
.lineThrough, .lineThrough,
color: AppColors color: Theme.of(
.navyGray, context)
.colorScheme
.onSurfaceVariant,
), ),
); );
} }
@@ -221,10 +233,12 @@ class EventAnalysisCard extends StatelessWidget {
}, },
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon( Icon(
Icons.arrow_forward, Icons.arrow_forward,
size: 12, size: 12,
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FutureBuilder<String>( FutureBuilder<String>(
@@ -237,12 +251,14 @@ class EventAnalysisCard extends StatelessWidget {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
snapshot.data!, snapshot.data!,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: fontWeight:
FontWeight.bold, FontWeight.bold,
color: color:
Color(0xFF10B981), Theme.of(context)
.colorScheme
.success,
), ),
); );
} }
@@ -260,17 +276,22 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF6B6B) color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
borderRadius: borderRadius:
BorderRadius.circular(4), BorderRadius.circular(4),
), ),
child: Text( child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}', _formatDiscountPercent(
style: const TextStyle( context, discountRate),
style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, 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';
}

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/color_scheme_ext.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart'; // Glass 제거: Material 3 Card 사용
import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/reduce_motion.dart'; import '../../utils/reduce_motion.dart';
@@ -75,11 +75,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
} }
// 월간 지출 차트 데이터 // 월간 지출 차트 데이터
List<BarChartGroupData> _getMonthlyBarGroups(String locale) { List<BarChartGroupData> _getMonthlyBarGroups(
BuildContext context, String locale) {
final List<BarChartGroupData> barGroups = []; final List<BarChartGroupData> barGroups = [];
final calculatedMax = monthlyData.fold<double>( final calculatedMax = monthlyData.fold<double>(
0, (max, data) => math.max(max, data['totalExpense'] as double)); 0, (max, data) => math.max(max, data['totalExpense'] as double));
final maxAmount = _calculateChartMaxY(calculatedMax, locale); final maxAmount = _calculateChartMaxY(calculatedMax, locale);
final scheme = Theme.of(context).colorScheme;
for (int i = 0; i < monthlyData.length; i++) { for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i]; final data = monthlyData[i];
@@ -89,20 +91,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
barRods: [ barRods: [
BarChartRodData( BarChartRodData(
toY: data['totalExpense'], toY: data['totalExpense'],
gradient: LinearGradient( color: scheme.primary,
colors: [
const Color(0xFF3B82F6).withValues(alpha: 0.7),
const Color(0xFF60A5FA),
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
),
width: 18, width: 18,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData( backDrawRodData: BackgroundBarChartRodData(
show: true, show: true,
toY: maxAmount, 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, parent: animationController,
curve: const Interval(0.4, 0.9, curve: Curves.easeOut), curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
)), )),
child: GlassmorphismCard( child: Card(
blur: 10, elevation: 3,
opacity: 0.1, shape: RoundedRectangleBorder(
borderRadius: 16, borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -168,7 +170,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
(max, data) => math.max( (max, data) => math.max(
max, data['totalExpense'] as double)), max, data['totalExpense'] as double)),
locale), locale),
barGroups: _getMonthlyBarGroups(locale), barGroups: _getMonthlyBarGroups(context, locale),
gridData: FlGridData( gridData: FlGridData(
show: true, show: true,
drawVerticalLine: false, drawVerticalLine: false,
@@ -182,8 +184,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
CurrencyUtil.getDefaultCurrency(locale)), CurrencyUtil.getDefaultCurrency(locale)),
getDrawingHorizontalLine: (value) { getDrawingHorizontalLine: (value) {
return FlLine( return FlLine(
color: color: Theme.of(context)
AppColors.navyGray.withValues(alpha: 0.1), .colorScheme
.onSurfaceVariant
.withValues(alpha: 0.1),
strokeWidth: 1, strokeWidth: 1,
); );
}, },
@@ -222,14 +226,18 @@ class MonthlyExpenseChartCard extends StatelessWidget {
barTouchData: BarTouchData( barTouchData: BarTouchData(
enabled: true, enabled: true,
touchTooltipData: BarTouchTooltipData( touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppColors.darkNavy, tooltipBgColor: Theme.of(context)
.colorScheme
.inverseSurface,
tooltipRoundedRadius: 8, tooltipRoundedRadius: 8,
getTooltipItem: getTooltipItem:
(group, groupIndex, rod, rodIndex) { (group, groupIndex, rod, rodIndex) {
return BarTooltipItem( return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n', '${monthlyData[group.x]['monthName']}\n',
const TextStyle( TextStyle(
color: AppColors.pureWhite, color: Theme.of(context)
.colorScheme
.onInverseSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
children: [ children: [
@@ -239,8 +247,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
monthlyData[group.x] monthlyData[group.x]
['totalExpense'] as double, ['totalExpense'] as double,
locale), locale),
style: const TextStyle( style: TextStyle(
color: Color(0xFFFBBF24), color: Theme.of(context)
.colorScheme
.warning,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),

View File

@@ -4,8 +4,9 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../services/exchange_rate_service.dart'; import '../../services/exchange_rate_service.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart'; import '../themed_text.dart';
import 'analysis_badge.dart'; import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -30,18 +31,19 @@ class SubscriptionPieChartCard extends StatefulWidget {
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> { class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
int _touchedIndex = -1; int _touchedIndex = -1;
late Future<List<PieChartSectionData>> _pieSectionsFuture; // kept for compatibility previously; computation now happens per build
String? _lastLocale; String? _lastLocale;
static const _chartColors = [ // 차트 팔레트: ColorScheme + 보조 상수(성공/경고/액센트)
Color(0xFF3B82F6), List<Color> _getChartColors(ColorScheme scheme) => [
Color(0xFF10B981), scheme.primary,
Color(0xFFF59E0B), scheme.success,
Color(0xFFEF4444), scheme.warning,
Color(0xFF8B5CF6), scheme.error,
Color(0xFF0EA5E9), scheme.tertiary,
Color(0xFFEC4899), scheme.secondary,
]; const Color(0xFFEC4899), // accent
];
@override @override
void initState() { void initState() {
@@ -62,7 +64,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
void _initializeFuture() { void _initializeFuture() {
_lastLocale = context.read<LocaleProvider>().locale.languageCode; _lastLocale = context.read<LocaleProvider>().locale.languageCode;
_pieSectionsFuture = _getPieSections(); // no-op: Future computed on demand in build
} }
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) { bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
@@ -85,6 +87,9 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 현재 locale 가져오기 // 현재 locale 가져오기
final locale = context.read<LocaleProvider>().locale.languageCode; final locale = context.read<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
// Chart palette (capture scheme before any awaits)
final scheme = Theme.of(context).colorScheme;
final chartColors = _getChartColors(scheme);
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산) // 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
List<double> sectionValues = []; List<double> sectionValues = [];
@@ -121,7 +126,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 섹션 데이터 생성 (터치 상태 제외) // 섹션 데이터 생성 (터치 상태 제외)
final sections = List.generate(widget.subscriptions.length, (i) { final sections = List.generate(widget.subscriptions.length, (i) {
final percentage = (sectionValues[i] / sectionsTotal) * 100; final percentage = (sectionValues[i] / sectionsTotal) * 100;
final index = i % _chartColors.length; final index = i % chartColors.length;
return PieChartSectionData( return PieChartSectionData(
value: sectionValues[i], value: sectionValues[i],
@@ -129,12 +134,12 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
titleStyle: const TextStyle( titleStyle: const TextStyle(
fontSize: 12.0, fontSize: 12.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: Colors.white,
shadows: [ shadows: [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
], ],
), ),
color: _chartColors[index], color: chartColors[index],
radius: 100.0, radius: 100.0,
titlePositionPercentageOffset: 0.6, titlePositionPercentageOffset: 0.6,
badgeWidget: null, badgeWidget: null,
@@ -150,12 +155,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
if (index >= widget.subscriptions.length) return const SizedBox.shrink(); if (index >= widget.subscriptions.length) return const SizedBox.shrink();
final subscription = widget.subscriptions[index]; final subscription = widget.subscriptions[index];
final colorIndex = index % _chartColors.length; final chartColors = _getChartColors(Theme.of(context).colorScheme);
final colorIndex = index % chartColors.length;
return IgnorePointer( return IgnorePointer(
child: AnalysisBadge( child: AnalysisBadge(
size: 40, size: 40,
borderColor: _chartColors[colorIndex], borderColor: chartColors[colorIndex],
subscription: subscription, subscription: subscription,
), ),
); );
@@ -177,7 +183,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
TextStyle( TextStyle(
fontSize: fontSize, fontSize: fontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: Colors.white,
shadows: const [ shadows: const [
Shadow( Shadow(
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
@@ -210,10 +216,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
parent: widget.animationController, parent: widget.animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeOut), curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
)), )),
child: GlassmorphismCard( child: Card(
blur: 10, elevation: 3,
opacity: 0.1, shape: RoundedRectangleBorder(
borderRadius: 16, borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -243,20 +256,27 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE5F2FF), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: const Color(0xFFBFDBFE), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Text( child: Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!), .exchangeRateFormat(snapshot.data!),
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6), color:
Theme.of(context).colorScheme.primary,
), ),
), ),
); );
@@ -291,7 +311,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
: SizedBox( : SizedBox(
height: 250, height: 250,
child: FutureBuilder<List<PieChartSectionData>>( child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture, future: _getPieSections(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == if (snapshot.connectionState ==
ConnectionState.waiting) { ConnectionState.waiting) {
@@ -392,8 +412,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
(index) { (index) {
final subscription = final subscription =
widget.subscriptions[index]; widget.subscriptions[index];
final chartColors = _getChartColors(
Theme.of(context).colorScheme);
final color = final color =
_chartColors[index % _chartColors.length]; chartColors[index % chartColors.length];
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 4.0), padding: const EdgeInsets.only(bottom: 4.0),
child: Row( child: Row(

View File

@@ -6,8 +6,9 @@ import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../utils/haptic_feedback_helper.dart'; import '../../utils/haptic_feedback_helper.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -44,10 +45,17 @@ class TotalExpenseSummaryCard extends StatelessWidget {
curve: const Interval(0.2, 0.8, curve: Curves.easeOut), curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
)), )),
child: RepaintBoundary( child: RepaintBoundary(
child: GlassmorphismCard( child: Card(
blur: 10, elevation: 3,
opacity: 0.1, shape: RoundedRectangleBorder(
borderRadius: 16, borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -85,8 +93,6 @@ class TotalExpenseSummaryCard extends StatelessWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
backgroundColor: AppColors.glassBackground
.withValues(alpha: 0.3),
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
@@ -142,18 +148,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassBackground color: Theme.of(context)
.withValues(alpha: 0.3), .colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.glassBorder color: Theme.of(context)
.withValues(alpha: 0.2), .colorScheme
.outline
.withValues(alpha: 0.3),
), ),
), ),
child: const FaIcon( child: FaIcon(
FontAwesomeIcons.listCheck, FontAwesomeIcons.listCheck,
size: 16, size: 16,
color: AppColors.primaryColor, color: Theme.of(context)
.colorScheme
.primary,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -189,18 +201,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassBackground color: Theme.of(context)
.withValues(alpha: 0.3), .colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.glassBorder color: Theme.of(context)
.withValues(alpha: 0.2), .colorScheme
.outline
.withValues(alpha: 0.3),
), ),
), ),
child: const FaIcon( child: FaIcon(
FontAwesomeIcons.chartLine, FontAwesomeIcons.chartLine,
size: 16, size: 16,
color: AppColors.successColor, color: Theme.of(context)
.colorScheme
.success,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),

View File

@@ -130,9 +130,11 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
alignment: Alignment.center, alignment: Alignment.center,
transform: Matrix4.identity() transform: Matrix4.identity()
..setEntry(3, 2, 0.001) ..setEntry(3, 2, 0.001)
..rotateZ(rotateAnimation.value) ..rotateZ(rotateAnimation.value),
..scale(scaleAnimation.value), child: Transform.scale(
child: child, scale: scaleAnimation.value,
child: child,
),
); );
}, },
); );
@@ -219,7 +221,10 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
FadeTransition( FadeTransition(
opacity: animation, opacity: animation,
child: Container( child: Container(
color: Colors.black.withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.scrim
.withValues(alpha: 0.3),
), ),
), ),
// 컨테이너 확장 애니메이션 // 컨테이너 확장 애니메이션

View File

@@ -18,7 +18,21 @@ class AnimatedWaveBackground extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final reduce = ReduceMotion.isEnabled(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( return Stack(
children: [ children: [
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용 // 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
@@ -26,22 +40,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller, animation: controller,
builder: (context, child) { builder: (context, child) {
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성 // 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
final angle = controller.value * 2 * math.pi; final p = paramsFor(200);
// 사인 함수를 사용하여 부드러운 움직임 생성 final angle = controller.value * 2 * math.pi * p.speedMult;
final xOffset = 20 * amp * math.sin(angle); // 사인 함수를 사용하여 부드러운 움직임 생성 (큰 원: 차분)
final yOffset = 10 * amp * math.cos(angle); final xOffset = 20 * amp * p.ampScale * math.sin(angle);
final yOffset = 10 * amp * p.ampScale * math.cos(angle);
return Positioned( return Positioned(
right: -40 + xOffset, right: -40 + xOffset,
top: -60 + yOffset, top: -60 + yOffset,
child: Transform.rotate( 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( child: Container(
width: 200, width: 200,
height: 200, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(100), borderRadius: BorderRadius.circular(100),
), ),
), ),
@@ -53,21 +71,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller, animation: controller,
builder: (context, child) { builder: (context, child) {
// 첫 번째 원과 약간 다른 위상을 가지도록 설정 // 첫 번째 원과 약간 다른 위상을 가지도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi / 3); final p = paramsFor(220);
final xOffset = 20 * amp * math.cos(angle); final angle =
final yOffset = 10 * amp * math.sin(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( return Positioned(
left: -80 + xOffset, left: -80 + xOffset,
bottom: -70 + yOffset, bottom: -70 + yOffset,
child: Transform.rotate( 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( child: Container(
width: 220, width: 220,
height: 220, height: 220,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(110), borderRadius: BorderRadius.circular(110),
), ),
), ),
@@ -80,20 +103,25 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller, animation: controller,
builder: (context, child) { builder: (context, child) {
// 세 번째 원은 다른 위상으로 움직이도록 설정 // 세 번째 원은 다른 위상으로 움직이도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3); final p = paramsFor(120);
final xOffset = 15 * amp * math.sin(angle * 0.7); final angle = (controller.value * 2 * math.pi * p.speedMult) +
final yOffset = 8 * amp * math.cos(angle * 0.7); (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( return Positioned(
right: 40 + xOffset, right: 40 + xOffset,
bottom: -40 + yOffset, bottom: -40 + yOffset,
child: Transform.rotate( 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( child: Container(
width: 120, width: 120,
height: 120, height: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08), color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(60), borderRadius: BorderRadius.circular(60),
), ),
), ),
@@ -112,7 +140,7 @@ class AnimatedWaveBackground extends StatelessWidget {
width: 30, width: 30,
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues( color: Theme.of(context).colorScheme.onSurface.withValues(
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value), alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
borderRadius: BorderRadius.circular(15), 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});
}

View File

@@ -36,27 +36,27 @@ class CategoryHeaderWidget extends StatelessWidget {
children: [ children: [
Text( Text(
categoryName, categoryName,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF374151), color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
Text( Text(
_buildCostDisplay(context), _buildCostDisplay(context),
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF6B7280), color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Divider( Divider(
height: 1, height: 1,
thickness: 1, thickness: 1,
color: Color(0xFFEEEEEE), color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
), ),
], ],
), ),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 주요 액션에 사용되는 Primary 버튼 /// 주요 액션에 사용되는 Primary 버튼
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다. /// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
@@ -44,26 +43,30 @@ class _PrimaryButtonState extends State<PrimaryButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectiveBackgroundColor = final effectiveBackgroundColor =
widget.backgroundColor ?? theme.primaryColor; widget.backgroundColor ?? theme.colorScheme.primary;
final effectiveForegroundColor = final effectiveForegroundColor =
widget.foregroundColor ?? AppColors.pureWhite; widget.foregroundColor ?? theme.colorScheme.onPrimary;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: widget.width ?? double.infinity, width: widget.width ?? double.infinity,
height: widget.height, height: widget.height,
transform: widget.enableHoverEffect && _isHovered transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02)) ? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
: Matrix4.identity(), : Matrix4.identity(),
child: ElevatedButton( child: ElevatedButton(
onPressed: widget.isLoading ? null : widget.onPressed, onPressed: widget.isLoading ? null : widget.onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: effectiveBackgroundColor, backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor, foregroundColor: effectiveForegroundColor,
// 고정 높이와 텍스트 잘림 방지를 위해 최소 사이즈 지정
minimumSize: Size.fromHeight(widget.height),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.borderRadius), 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, elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08), shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor: disabledBackgroundColor:

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 부차적인 액션에 사용되는 Secondary 버튼 /// 부차적인 액션에 사용되는 Secondary 버튼
/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다. /// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다.
@@ -42,15 +41,17 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; final theme = Theme.of(context);
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; final effectiveBorderColor =
widget.borderColor ?? theme.colorScheme.outline;
final effectiveTextColor = widget.textColor ?? theme.colorScheme.primary;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
transform: widget.enableHoverEffect && _isHovered transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02)) ? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
: Matrix4.identity(), : Matrix4.identity(),
child: OutlinedButton( child: OutlinedButton(
onPressed: widget.onPressed, onPressed: widget.onPressed,
@@ -70,8 +71,9 @@ class _SecondaryButtonState extends State<SecondaryButton> {
vertical: 12, vertical: 12,
horizontal: 24, horizontal: 24,
), ),
backgroundColor: backgroundColor: _isHovered
_isHovered ? AppColors.glassBackground : Colors.transparent, ? theme.colorScheme.onSurface.withValues(alpha: 0.06)
: Colors.transparent,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -140,7 +142,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectiveColor = widget.color ?? AppColors.primaryColor; final effectiveColor = widget.color ?? theme.colorScheme.primary;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/color_scheme_ext.dart';
/// 확인 다이얼로그 위젯 /// 확인 다이얼로그 위젯
/// 사용자에게 중요한 작업을 확인받을 때 사용합니다. /// 사용자에게 중요한 작업을 확인받을 때 사용합니다.
@@ -99,7 +100,9 @@ class ConfirmationDialog extends StatelessWidget {
), ),
child: Text( child: Text(
confirmText, confirmText,
style: const TextStyle(color: Colors.white), style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
), ),
), ),
], ],
@@ -164,12 +167,13 @@ class SuccessDialog extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1), color:
Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: Icon(
Icons.check_circle, Icons.check_circle,
color: Colors.green, color: Theme.of(context).colorScheme.success,
size: 64, size: 64,
), ),
), ),
@@ -188,7 +192,7 @@ class SuccessDialog extends StatelessWidget {
message!, message!,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -203,7 +207,7 @@ class SuccessDialog extends StatelessWidget {
onPressed?.call(); onPressed?.call();
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.green, backgroundColor: Theme.of(context).colorScheme.success,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -214,8 +218,8 @@ class SuccessDialog extends StatelessWidget {
), ),
child: Text( child: Text(
buttonText, buttonText,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16, fontSize: 16,
), ),
), ),
@@ -272,12 +276,12 @@ class ErrorDialog extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1), color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: Icon(
Icons.error_outline, Icons.error_outline,
color: Colors.red, color: Theme.of(context).colorScheme.error,
size: 64, size: 64,
), ),
), ),
@@ -296,7 +300,7 @@ class ErrorDialog extends StatelessWidget {
message!, message!,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey[600], color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -311,7 +315,7 @@ class ErrorDialog extends StatelessWidget {
onPressed?.call(); onPressed?.call();
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: Theme.of(context).colorScheme.error,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -322,8 +326,8 @@ class ErrorDialog extends StatelessWidget {
), ),
child: Text( child: Text(
buttonText, buttonText,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onPrimary,
fontSize: 16, fontSize: 16,
), ),
), ),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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) ...[ if (label != null) ...[
Text( Text(
label!, label!,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -89,22 +89,22 @@ class BaseTextField extends StatelessWidget {
maxLines: maxLines, maxLines: maxLines,
minLines: minLines, minLines: minLines,
readOnly: readOnly, readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor, cursorColor: cursorColor ?? theme.colorScheme.primary,
style: style ?? style: style ??
const TextStyle( TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.textPrimary, color: Theme.of(context).colorScheme.onSurface,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: const TextStyle( hintStyle: TextStyle(
color: AppColors.textMuted, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
prefixIcon: prefixIcon, prefixIcon: prefixIcon,
prefixText: prefixText, prefixText: prefixText,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
filled: true, filled: true,
fillColor: fillColor ?? AppColors.surfaceColorAlt, fillColor: fillColor ?? Theme.of(context).colorScheme.surface,
contentPadding: contentPadding ?? const EdgeInsets.all(16), contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -113,15 +113,15 @@ class BaseTextField extends StatelessWidget {
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide( borderSide: BorderSide(
color: theme.primaryColor, color: theme.colorScheme.primary,
width: 2, width: 2,
), ),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide( borderSide: BorderSide(
color: AppColors.borderColor.withValues(alpha: 0.7), color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1.5, width: 1,
), ),
), ),
disabledBorder: OutlineInputBorder( disabledBorder: OutlineInputBorder(

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart'; // import '../../../theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
/// 결제 주기 선택 위젯 /// 결제 주기 선택 위젯
@@ -8,8 +8,8 @@ class BillingCycleSelector extends StatelessWidget {
final String billingCycle; final String billingCycle;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final Color? baseColor; final Color? baseColor;
final List<Color>? gradientColors; final List<Color>? gradientColors; // deprecated: ignored
final bool isGlassmorphism; final bool isGlassmorphism; // deprecated: ignored
const BillingCycleSelector({ const BillingCycleSelector({
super.key, super.key,
@@ -24,19 +24,12 @@ class BillingCycleSelector extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localization = AppLocalizations.of(context); final localization = AppLocalizations.of(context);
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism final cycles = [
? [ localization.monthly,
localization.billingCycleMonthly, localization.billingCycleQuarterly,
localization.billingCycleQuarterly, localization.billingCycleHalfYearly,
localization.billingCycleHalfYearly, localization.yearly,
localization.billingCycleYearly, ];
]
: [
localization.monthly,
localization.billingCycleQuarterly,
localization.billingCycleHalfYearly,
localization.yearly,
];
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -54,16 +47,16 @@ class BillingCycleSelector extends StatelessWidget {
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBackgroundColor(isSelected), color: _getBackgroundColor(context, isSelected),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected), border: _getBorder(context, isSelected),
), ),
child: Text( child: Text(
cycle, cycle,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: _getTextColor(isSelected), color: _getTextColor(context, isSelected),
), ),
), ),
), ),
@@ -74,38 +67,22 @@ class BillingCycleSelector extends StatelessWidget {
); );
} }
Color _getBackgroundColor(bool isSelected) { Color _getBackgroundColor(BuildContext context, bool isSelected) {
if (!isSelected) { final scheme = Theme.of(context).colorScheme;
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) {
if (isSelected) { 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;
} }
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../theme/app_colors.dart'; // import '../../../theme/app_colors.dart';
import '../../../providers/category_provider.dart'; import '../../../providers/category_provider.dart';
/// 카테고리 선택 위젯 /// 카테고리 선택 위젯
@@ -10,8 +10,8 @@ class CategorySelector extends StatelessWidget {
final String? selectedCategoryId; final String? selectedCategoryId;
final ValueChanged<String?> onChanged; final ValueChanged<String?> onChanged;
final Color? baseColor; final Color? baseColor;
final List<Color>? gradientColors; final List<Color>? gradientColors; // deprecated: ignored
final bool isGlassmorphism; final bool isGlassmorphism; // deprecated: ignored
const CategorySelector({ const CategorySelector({
super.key, super.key,
@@ -39,9 +39,9 @@ class CategorySelector extends StatelessWidget {
vertical: 10, vertical: 10,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBackgroundColor(isSelected), color: _getBackgroundColor(context, isSelected),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected), border: _getBorder(context, isSelected),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -49,7 +49,7 @@ class CategorySelector extends StatelessWidget {
Icon( Icon(
_getCategoryIcon(category), _getCategoryIcon(category),
size: 18, size: 18,
color: _getTextColor(isSelected), color: _getTextColor(context, isSelected),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Consumer<CategoryProvider>( Consumer<CategoryProvider>(
@@ -60,7 +60,7 @@ class CategorySelector extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: _getTextColor(isSelected), color: _getTextColor(context, isSelected),
), ),
); );
}, },
@@ -100,38 +100,22 @@ class CategorySelector extends StatelessWidget {
} }
} }
Color _getBackgroundColor(bool isSelected) { Color _getBackgroundColor(BuildContext context, bool isSelected) {
if (!isSelected) { final scheme = Theme.of(context).colorScheme;
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) {
if (isSelected) { 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;
} }
} }

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
class CurrencyDropdownField extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const CurrencyDropdownField({
super.key,
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DropdownButtonFormField<String>(
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,
),
),
],
);
}
}

View File

@@ -5,10 +5,10 @@ import 'base_text_field.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
/// 통화 입력 필드 위젯 /// 통화 입력 필드 위젯
/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다. /// KRW/JPY(정수), USD/CNY(소수점 2자리)를 지원하며 자동 포맷팅을 제공합니다.
class CurrencyInputField extends StatefulWidget { class CurrencyInputField extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final String currency; // 'KRW' or 'USD' final String currency; // 'KRW' | 'USD' | 'JPY' | 'CNY'
final String? label; final String? label;
final String? hintText; final String? hintText;
final Function(double?)? onChanged; final Function(double?)? onChanged;
@@ -39,6 +39,7 @@ class CurrencyInputField extends StatefulWidget {
class _CurrencyInputFieldState extends State<CurrencyInputField> { class _CurrencyInputFieldState extends State<CurrencyInputField> {
late FocusNode _focusNode; late FocusNode _focusNode;
bool _isFormatted = false; bool _isFormatted = false;
bool _isPostFrameUpdating = false;
@override @override
void initState() { void initState() {
@@ -66,6 +67,29 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
super.dispose(); 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() { void _onFocusChanged() {
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) { if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
// 포커스를 잃었을 때 포맷팅 적용 // 포커스를 잃었을 때 포맷팅 적용
@@ -81,7 +105,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
final value = _parseValue(widget.controller.text); final value = _parseValue(widget.controller.text);
if (value != null) { if (value != null) {
setState(() { setState(() {
if (widget.currency == 'KRW') { if (_isIntegerCurrency(widget.currency)) {
widget.controller.text = value.toInt().toString(); widget.controller.text = value.toInt().toString();
} else { } else {
widget.controller.text = value.toString(); widget.controller.text = value.toString();
@@ -97,7 +121,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
} }
String _formatCurrency(double value) { String _formatCurrency(double value) {
if (widget.currency == 'KRW') { if (_isIntegerCurrency(widget.currency)) {
return NumberFormat.decimalPattern().format(value.toInt()); return NumberFormat.decimalPattern().format(value.toInt());
} else { } else {
return NumberFormat('#,##0.00').format(value); return NumberFormat('#,##0.00').format(value);
@@ -108,13 +132,26 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
final cleanText = text final cleanText = text
.replaceAll(',', '') .replaceAll(',', '')
.replaceAll('', '') .replaceAll('', '')
.replaceAll('¥', '')
.replaceAll('', '')
.replaceAll('\$', '') .replaceAll('\$', '')
.trim(); .trim();
return double.tryParse(cleanText); return double.tryParse(cleanText);
} }
// ignore: unused_element
String get _prefixText { 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) { String _getDefaultHintText(BuildContext context) {
@@ -132,26 +169,27 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow( FilteringTextInputFormatter.allow(
widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')), _isIntegerCurrency(widget.currency)
if (widget.currency == 'USD') ? RegExp(r'[0-9]')
// USD의 경우 소수점 이하 2자리까지만 허용 : RegExp(r'[0-9.]'),
),
if (!_isIntegerCurrency(widget.currency))
// 소수 통화(USD/CNY): 소수점 이하 2자리 제한
TextInputFormatter.withFunction((oldValue, newValue) { TextInputFormatter.withFunction((oldValue, newValue) {
final text = newValue.text; final text = newValue.text;
if (text.isEmpty) return newValue; if (text.isEmpty) return newValue;
final parts = text.split('.'); final parts = text.split('.');
if (parts.length > 2) { if (parts.length > 2) {
// 소수점이 2개 이상인 경우 거부 return oldValue; // 소수점이 2개 이상인 경우 거부
return oldValue;
} }
if (parts.length == 2 && parts[1].length > 2) { if (parts.length == 2 && parts[1].length > 2) {
// 소수점 이하 2자리를 초과하는 경우 거부 return oldValue; // 소수점 이하 2자 초과 거부
return oldValue;
} }
return newValue; return newValue;
}), }),
], ],
prefixText: _prefixText, prefixText: _getPrefixText(),
onEditingComplete: widget.onEditingComplete, onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled, enabled: widget.enabled,
onChanged: (value) { onChanged: (value) {
@@ -172,3 +210,23 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
); );
} }
} }
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)} ';
}

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart'; // import '../../../theme/app_colors.dart';
/// 통화 선택 위젯 /// 통화 선택 위젯
/// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다. /// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다.
class CurrencySelector extends StatelessWidget { class CurrencySelector extends StatelessWidget {
final String currency; final String currency;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final bool isGlassmorphism; final bool isGlassmorphism; // deprecated: ignored
const CurrencySelector({ const CurrencySelector({
super.key, super.key,
@@ -72,7 +72,7 @@ class _CurrencyOption extends StatelessWidget {
final String? subtitle; final String? subtitle;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final bool isGlassmorphism; final bool isGlassmorphism; // deprecated: ignored
const _CurrencyOption({ const _CurrencyOption({
required this.label, required this.label,
@@ -96,7 +96,7 @@ class _CurrencyOption extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBackgroundColor(theme), color: _getBackgroundColor(theme),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: _getBorder(), border: _getBorder(theme),
), ),
child: Center( child: Center(
child: Column( child: Column(
@@ -107,7 +107,7 @@ class _CurrencyOption extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _getTextColor(), color: _getTextColor(theme),
), ),
), ),
if (subtitle != null) ...[ if (subtitle != null) ...[
@@ -117,7 +117,7 @@ class _CurrencyOption extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w500, 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) { Color _getBackgroundColor(ThemeData theme) {
if (isSelected) { final scheme = theme.colorScheme;
return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6); return isSelected ? scheme.primary : scheme.surface;
}
return isGlassmorphism
? AppColors.surfaceColorAlt
: Colors.grey.withValues(alpha: 0.1);
} }
Border? _getBorder() { Border? _getBorder(ThemeData theme) {
if (isSelected || !isGlassmorphism) { if (isSelected) return null;
return null;
}
return Border.all( return Border.all(
color: AppColors.borderColor, color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1.5, width: 1,
); );
} }
Color _getTextColor() { Color _getTextColor(ThemeData theme) {
if (isSelected) { final scheme = theme.colorScheme;
return Colors.white; return isSelected ? scheme.onPrimary : scheme.onSurface;
}
return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!;
} }
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart'; // import '../../../theme/app_colors.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
/// 날짜 선택 필드 위젯 /// 날짜 선택 필드 위젯
@@ -48,10 +48,10 @@ class DatePickerField extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -67,13 +67,14 @@ class DatePickerField extends StatelessWidget {
lastDate: lastDate ?? lastDate: lastDate ??
DateTime.now().add(const Duration(days: 365 * 10)), DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) { builder: (BuildContext context, Widget? child) {
final cs = Theme.of(context).colorScheme;
return Theme( return Theme(
data: ThemeData.light().copyWith( data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light( colorScheme: cs.copyWith(
primary: effectivePrimaryColor, primary: effectivePrimaryColor,
onPrimary: Colors.white, onPrimary: cs.onPrimary,
surface: Colors.white, surface: cs.surface,
onSurface: Colors.black, onSurface: cs.onSurface,
), ),
), ),
child: child!, child: child!,
@@ -90,10 +91,13 @@ class DatePickerField extends StatelessWidget {
child: Container( child: Container(
padding: contentPadding ?? const EdgeInsets.all(16), padding: contentPadding ?? const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor ?? AppColors.surfaceColorAlt, color: backgroundColor ?? Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppColors.borderColor.withValues(alpha: 0.7), color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.6),
width: 1.5, width: 1.5,
), ),
), ),
@@ -105,15 +109,18 @@ class DatePickerField extends StatelessWidget {
.format(selectedDate), .format(selectedDate),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: color: enabled
enabled ? AppColors.textPrimary : AppColors.textMuted, ? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
), ),
Icon( Icon(
Icons.calendar_today, Icons.calendar_today,
size: 20, 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, firstDate: firstDate,
lastDate: lastDate, lastDate: lastDate,
builder: (BuildContext context, Widget? child) { builder: (BuildContext context, Widget? child) {
final cs = Theme.of(context).colorScheme;
return Theme( return Theme(
data: ThemeData.light().copyWith( data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light( colorScheme: cs.copyWith(
primary: effectivePrimaryColor, primary: effectivePrimaryColor,
onPrimary: Colors.white, onPrimary: cs.onPrimary,
surface: Colors.white, surface: cs.surface,
onSurface: Colors.black, onSurface: cs.onSurface,
), ),
), ),
child: child!, child: child!,
@@ -237,10 +245,10 @@ class _DateRangeItem extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceColorAlt, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppColors.borderColor.withValues(alpha: 0.7), color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.6),
width: 1.5, width: 1.5,
), ),
), ),
@@ -249,9 +257,9 @@ class _DateRangeItem extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textSecondary, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -263,8 +271,9 @@ class _DateRangeItem extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: color: date != null
date != null ? AppColors.textPrimary : AppColors.textMuted, ? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],

View File

@@ -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,
),
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart'; import '../../../theme/color_scheme_ext.dart';
/// 앱 전체에서 사용되는 통합 스낵바 /// 앱 전체에서 사용되는 통합 스낵바
/// 성공, 에러, 정보 등 다양한 타입의 메시지를 표시합니다. /// 성공, 에러, 정보 등 다양한 타입의 메시지를 표시합니다.
@@ -16,9 +16,9 @@ class AppSnackBar {
context: context, context: context,
message: message, message: message,
icon: icon, icon: icon,
backgroundColor: AppColors.successColor, backgroundColor: Theme.of(context).colorScheme.success,
iconColor: AppColors.pureWhite, iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: AppColors.pureWhite, textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration, duration: duration,
showAtTop: showAtTop, showAtTop: showAtTop,
); );
@@ -32,13 +32,14 @@ class AppSnackBar {
Duration duration = const Duration(seconds: 4), Duration duration = const Duration(seconds: 4),
bool showAtTop = true, bool showAtTop = true,
}) { }) {
final cs = Theme.of(context).colorScheme;
_show( _show(
context: context, context: context,
message: message, message: message,
icon: icon, icon: icon,
backgroundColor: AppColors.dangerColor, backgroundColor: cs.error,
iconColor: AppColors.pureWhite, iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: AppColors.pureWhite, textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration, duration: duration,
showAtTop: showAtTop, showAtTop: showAtTop,
); );
@@ -56,9 +57,9 @@ class AppSnackBar {
context: context, context: context,
message: message, message: message,
icon: icon, icon: icon,
backgroundColor: AppColors.primaryColor, backgroundColor: Theme.of(context).colorScheme.primary,
iconColor: AppColors.pureWhite, iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: AppColors.pureWhite, textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration, duration: duration,
showAtTop: showAtTop, showAtTop: showAtTop,
); );
@@ -76,9 +77,9 @@ class AppSnackBar {
context: context, context: context,
message: message, message: message,
icon: icon, icon: icon,
backgroundColor: AppColors.warningColor, backgroundColor: Theme.of(context).colorScheme.warning,
iconColor: AppColors.pureWhite, iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: AppColors.pureWhite, textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration, duration: duration,
showAtTop: showAtTop, showAtTop: showAtTop,
); );
@@ -90,8 +91,8 @@ class AppSnackBar {
required String message, required String message,
required IconData icon, required IconData icon,
required Color backgroundColor, required Color backgroundColor,
Color iconColor = AppColors.pureWhite, Color iconColor = Colors.white,
Color textColor = AppColors.pureWhite, Color textColor = Colors.white,
Duration duration = const Duration(seconds: 3), Duration duration = const Duration(seconds: 3),
bool showAtTop = true, bool showAtTop = true,
SnackBarAction? action, SnackBarAction? action,
@@ -200,25 +201,25 @@ class AppSnackBar {
width: 24, width: 24,
height: 24, height: 24,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
child: const CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: AppColors.pureWhite, color: Theme.of(context).colorScheme.onPrimary,
), ),
), ),
// 메시지 // 메시지
Expanded( Expanded(
child: Text( child: Text(
message, message,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.pureWhite, color: Theme.of(context).colorScheme.onPrimary,
), ),
), ),
), ),
], ],
), ),
backgroundColor: AppColors.primaryColor, backgroundColor: Theme.of(context).colorScheme.primary,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: showAtTop margin: showAtTop
? EdgeInsets.only( ? EdgeInsets.only(
@@ -249,7 +250,7 @@ class AppSnackBar {
required String actionLabel, required String actionLabel,
required VoidCallback onActionPressed, required VoidCallback onActionPressed,
IconData icon = Icons.info_rounded, IconData icon = Icons.info_rounded,
Color backgroundColor = AppColors.primaryColor, Color? backgroundColor,
Duration duration = const Duration(seconds: 4), Duration duration = const Duration(seconds: 4),
bool showAtTop = true, bool showAtTop = true,
}) { }) {
@@ -257,14 +258,14 @@ class AppSnackBar {
context: context, context: context,
message: message, message: message,
icon: icon, icon: icon,
backgroundColor: backgroundColor, backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary,
iconColor: AppColors.pureWhite, iconColor: Theme.of(context).colorScheme.onPrimary,
textColor: AppColors.pureWhite, textColor: Theme.of(context).colorScheme.onPrimary,
duration: duration, duration: duration,
showAtTop: showAtTop, showAtTop: showAtTop,
action: SnackBarAction( action: SnackBarAction(
label: actionLabel, label: actionLabel,
textColor: AppColors.pureWhite, textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: onActionPressed, onPressed: onActionPressed,
), ),
); );

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.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/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -38,19 +39,15 @@ class DetailEventSection extends StatelessWidget {
)), )),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassCard, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1), color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -78,10 +75,10 @@ class DetailEventSection extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
AppLocalizations.of(context).eventPrice, AppLocalizations.of(context).eventPrice,
style: const TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],
@@ -98,7 +95,8 @@ class DetailEventSection extends StatelessWidget {
controller.eventPriceController.clear(); controller.eventPriceController.clear();
} }
}, },
activeColor: baseColor, activeThumbColor: baseColor,
activeTrackColor: baseColor.withValues(alpha: 0.5),
), ),
], ],
), ),
@@ -109,27 +107,34 @@ class DetailEventSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08), color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.tertiary
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: Theme.of(context).colorScheme.tertiary,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).eventPriceHint, AppLocalizations.of(context).eventPriceHint,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color:
Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
@@ -228,7 +233,7 @@ class _DiscountBadge extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1), color: Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -236,15 +241,15 @@ class _DiscountBadge extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green, color: Theme.of(context).colorScheme.success,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.discountPercent .discountPercent
.replaceAll('@', discountPercentage.toString()), .replaceAll('@', discountPercentage.toString()),
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Theme.of(context).colorScheme.onPrimary,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -253,8 +258,8 @@ class _DiscountBadge extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
_getLocalizedDiscountAmount(context, currency, discountAmount), _getLocalizedDiscountAmount(context, currency, discountAmount),
style: const TextStyle( style: TextStyle(
color: Color(0xFF15803D), color: Theme.of(context).colorScheme.success,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),

View File

@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../providers/category_provider.dart'; import '../../providers/category_provider.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_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/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart'; import '../common/form_fields/category_selector.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -43,19 +43,15 @@ class DetailFormSection extends StatelessWidget {
)), )),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassCard, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1), color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -100,28 +96,18 @@ class DetailFormSection extends StatelessWidget {
children: [ children: [
Text( Text(
AppLocalizations.of(context).currency, AppLocalizations.of(context).currency,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color:
Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
CurrencySelector( CurrencyDropdownField(
currency: controller.currency, currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
controller.currency = 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: [ children: [
Text( Text(
AppLocalizations.of(context).billingCycle, AppLocalizations.of(context).billingCycle,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
BillingCycleSelector( BillingCycleSelector(
billingCycle: controller.billingCycle, billingCycle: controller.billingCycle,
baseColor: baseColor, baseColor: baseColor,
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
controller.billingCycle = value; controller.billingCycle = value;
}, },
@@ -163,7 +148,9 @@ class DetailFormSection extends StatelessWidget {
controller.nextBillingDate = date; controller.nextBillingDate = date;
}, },
label: AppLocalizations.of(context).nextBillingDate, label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(), // 과거 결제일을 가진 항목도 수정 가능하도록 범위 완화
firstDate: DateTime.now()
.subtract(const Duration(days: 365 * 10)),
lastDate: lastDate:
DateTime.now().add(const Duration(days: 365 * 2)), DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor, primaryColor: baseColor,
@@ -178,10 +165,10 @@ class DetailFormSection extends StatelessWidget {
children: [ children: [
Text( Text(
AppLocalizations.of(context).category, AppLocalizations.of(context).category,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -189,7 +176,6 @@ class DetailFormSection extends StatelessWidget {
categories: categoryProvider.categories, categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId, selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor, baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) { onChanged: (categoryId) {
controller.selectedCategoryId = categoryId; controller.selectedCategoryId = categoryId;
}, },

View File

@@ -30,11 +30,10 @@ class DetailHeaderSection extends StatelessWidget {
return Consumer<DetailScreenController>( return Consumer<DetailScreenController>(
builder: (context, controller, child) { builder: (context, controller, child) {
final baseColor = controller.getCardColor(); final baseColor = controller.getCardColor();
final gradient = controller.getGradient(baseColor);
return Container( return Container(
height: 320, height: 320,
decoration: BoxDecoration(gradient: gradient), decoration: BoxDecoration(color: baseColor),
child: Stack( child: Stack(
children: [ children: [
// 배경 패턴 // 배경 패턴

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.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/form_fields/base_text_field.dart';
import '../common/buttons/secondary_button.dart'; import '../common/buttons/secondary_button.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -35,19 +35,13 @@ class DetailUrlSection extends StatelessWidget {
)), )),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassCard, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1), color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -72,10 +66,10 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
AppLocalizations.of(context).websiteInfo, AppLocalizations.of(context).websiteInfo,
style: const TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, 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, label: AppLocalizations.of(context).websiteUrl,
hintText: AppLocalizations.of(context).urlExample, hintText: AppLocalizations.of(context).urlExample,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: const Icon( prefixIcon: Icon(
Icons.link_rounded, Icons.link_rounded,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
@@ -102,10 +96,16 @@ class DetailUrlSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.warningColor.withValues(alpha: 0.08), color: Theme.of(context)
.colorScheme
.warning
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppColors.warningColor.withValues(alpha: 0.4), color: Theme.of(context)
.colorScheme
.warning
.withValues(alpha: 0.4),
width: 1, width: 1,
), ),
), ),
@@ -114,18 +114,18 @@ class DetailUrlSection extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
const Icon( Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.warningColor, color: Theme.of(context).colorScheme.warning,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
AppLocalizations.of(context).cancelGuide, AppLocalizations.of(context).cancelGuide,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],
@@ -133,9 +133,9 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
AppLocalizations.of(context).cancelServiceGuide, AppLocalizations.of(context).cancelServiceGuide,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: 1.5, height: 1.5,
), ),
@@ -145,7 +145,7 @@ class DetailUrlSection extends StatelessWidget {
text: AppLocalizations.of(context).goToCancelPage, text: AppLocalizations.of(context).goToCancelPage,
icon: Icons.open_in_new_rounded, icon: Icons.open_in_new_rounded,
onPressed: controller.openCancellationPage, onPressed: controller.openCancellationPage,
color: AppColors.warningColor, color: Theme.of(context).colorScheme.warning,
), ),
], ],
), ),
@@ -158,27 +158,33 @@ class DetailUrlSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08), color: Theme.of(context)
.colorScheme
.info
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3), color: Theme.of(context)
.colorScheme
.info
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.auto_fix_high_rounded, Icons.auto_fix_high_rounded,
color: AppColors.infoColor, color: Theme.of(context).colorScheme.info,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).urlAutoMatchInfo, AppLocalizations.of(context).urlAutoMatchInfo,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:ui'; // Material 3 기반 다이얼로그
import '../../utils/reduce_motion.dart';
import '../../theme/app_colors.dart';
import '../common/buttons/primary_button.dart'; import '../common/buttons/primary_button.dart';
import '../common/buttons/secondary_button.dart'; import '../common/buttons/secondary_button.dart';
@@ -18,148 +16,133 @@ class DeleteConfirmationDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Dialog( return Dialog(
backgroundColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0, elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: Stack( padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// 글래스모피즘 배경 // 아이콘
ClipRRect( Container(
borderRadius: BorderRadius.circular(24), width: 80,
child: BackdropFilter( height: 80,
filter: ImageFilter.blur( decoration: BoxDecoration(
sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4), color:
sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4), 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( children: [
decoration: BoxDecoration( const TextSpan(text: '정말로 '),
color: AppColors.glassCard.withValues(alpha: 0.8), TextSpan(
borderRadius: BorderRadius.circular(24), text: serviceName,
border: Border.all( style: TextStyle(
color: AppColors.glassBorder, fontWeight: FontWeight.w600,
width: 1, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
padding: const EdgeInsets.all(32), const TextSpan(text: ' 구독을\n삭제하시겠습니까?'),
child: Column( ],
mainAxisSize: MainAxisSize.min, ),
children: [ ),
// 아이콘 const SizedBox(height: 8),
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 Text( Container(
'구독 삭제', padding: const EdgeInsets.symmetric(
style: TextStyle( horizontal: 16,
fontSize: 24, vertical: 12,
fontWeight: FontWeight.w700, ),
color: AppColors.textPrimary, decoration: BoxDecoration(
), color:
), Theme.of(context).colorScheme.error.withValues(alpha: 0.05),
const SizedBox(height: 12), borderRadius: BorderRadius.circular(12),
border: Border.all(
// 설명 color: Theme.of(context)
RichText( .colorScheme
textAlign: TextAlign.center, .error
text: TextSpan( .withValues(alpha: 0.2),
style: const TextStyle( width: 1,
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,
),
),
],
),
],
),
), ),
), ),
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<bool>( final result = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.5), barrierColor: Theme.of(context).colorScheme.scrim.withValues(alpha: 0.5),
builder: (context) => DeleteConfirmationDialog( builder: (context) => DeleteConfirmationDialog(
serviceName: serviceName, serviceName: serviceName,
), ),

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'glassmorphism_card.dart'; // Glass 제거: Material 3 Card로 대체
import 'themed_text.dart'; import 'themed_text.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/reduce_motion.dart'; import '../utils/reduce_motion.dart';
@@ -29,106 +29,109 @@ class EmptyStateWidget extends StatelessWidget {
final beginOffset = ReduceMotion.isEnabled(context) final beginOffset = ReduceMotion.isEnabled(context)
? const Offset(0, 0.05) ? const Offset(0, 0.05)
: const Offset(0, 0.2); : const Offset(0, 0.2);
final fade = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: fadeController, curve: Curves.easeIn),
);
final slide = Tween<Offset>(begin: beginOffset, end: Offset.zero).animate(
CurvedAnimation(parent: slideController, curve: Curves.easeOutBack),
);
return FadeTransition( return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate( opacity: fade,
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
child: Center( child: Center(
child: SlideTransition( child: SlideTransition(
position: Tween<Offset>( position: slide,
begin: beginOffset,
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutBack)),
child: RepaintBoundary( child: RepaintBoundary(
child: GlassmorphismCard( child: Card(
width: null,
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(32), elevation: 3,
child: Column( shape: RoundedRectangleBorder(
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(16),
children: [ side: BorderSide(
AnimatedBuilder( color: Theme.of(context)
animation: rotateController, .colorScheme
builder: (context, child) { .outline
final angleScale = .withValues(alpha: 0.5),
ReduceMotion.isEnabled(context) ? 0.2 : 1.0; ),
return Transform.rotate( ),
angle: child: Padding(
angleScale * rotateController.value * 2 * math.pi, padding: const EdgeInsets.all(32),
child: Container( child: Column(
padding: const EdgeInsets.all(24), mainAxisSize: MainAxisSize.min,
decoration: BoxDecoration( children: [
gradient: const LinearGradient( AnimatedBuilder(
colors: AppColors.blueGradient, animation: rotateController,
begin: Alignment.topLeft, builder: (context, child) {
end: Alignment.bottomRight, 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, const SizedBox(height: 32),
style: const TextStyle( ThemedText(
fontSize: 16, AppLocalizations.of(context).noSubscriptions,
fontWeight: FontWeight.w600, fontSize: 22,
letterSpacing: 0.5, fontWeight: FontWeight.w800,
color: AppColors.pureWhite, 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,
),
), ),
), ),
), ),
), ],
], ),
), ),
), ),
), ),

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../utils/reduce_motion.dart'; import '../utils/reduce_motion.dart';
@@ -69,11 +68,12 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
return AnimatedBuilder( return AnimatedBuilder(
animation: _animation, animation: _animation,
builder: (context, child) { builder: (context, child) {
final bottomInset = MediaQuery.of(context).padding.bottom;
return Positioned( return Positioned(
bottom: 20, bottom: 0,
left: 16, left: 16,
right: 16, right: 16,
height: 88, height: 88 + bottomInset,
child: Transform.translate( child: Transform.translate(
offset: Offset( offset: Offset(
0, 0,
@@ -83,26 +83,15 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
child: Opacity( child: Opacity(
opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value, opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value,
child: Container( child: Container(
margin: const EdgeInsets.all(4), // 그림자 공간 확보 margin: EdgeInsets.zero,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceColor, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: const [ boxShadow: const [],
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 12,
spreadRadius: 0,
offset: Offset(0, 4),
),
],
), ),
child: GlassmorphismCard( child: Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8), const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
borderRadius: 24,
blur: 10.0,
backgroundColor: Colors.transparent,
boxShadow: const [], // 그림자는 Container에서 처리
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
@@ -169,40 +158,50 @@ class _NavigationItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return Material(
onTap: onTap, color: Colors.transparent,
borderRadius: BorderRadius.circular(12), shape: RoundedRectangleBorder(
child: AnimatedContainer( borderRadius: BorderRadius.circular(12),
duration: const Duration(milliseconds: 200), ),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: InkWell(
decoration: BoxDecoration( onTap: onTap,
color: isSelected borderRadius: BorderRadius.circular(12),
? AppColors.primaryColor.withValues(alpha: 0.1) child: AnimatedContainer(
: Colors.transparent, duration: const Duration(milliseconds: 200),
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
), decoration: BoxDecoration(
child: Column( color: isSelected
mainAxisSize: MainAxisSize.min, ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1)
children: [ : Colors.transparent,
AnimatedContainer( borderRadius: BorderRadius.circular(12),
duration: const Duration(milliseconds: 200), ),
child: Icon( child: Column(
icon, mainAxisSize: MainAxisSize.min,
color: isSelected ? AppColors.primaryColor : AppColors.navyGray, children: [
size: isSelected ? 24 : 22, 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),
const SizedBox(height: 2), AnimatedDefaultTextStyle(
AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200),
duration: const Duration(milliseconds: 200), style: TextStyle(
style: TextStyle( fontSize: 10,
fontSize: 10, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected
color: isSelected ? AppColors.primaryColor : AppColors.navyGray, ? 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, width: 56,
height: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color: Theme.of(context).colorScheme.primary,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColors.blueGradient,
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 12,
offset: Offset(0, 4),
),
],
), ),
child: const Icon( child: Icon(
Icons.add_rounded, Icons.add_rounded,
color: AppColors.pureWhite, color: Theme.of(context).colorScheme.onPrimary,
size: 28, size: 28,
), ),
), ),

View File

@@ -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<Color>? 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<GlassmorphicScaffold> createState() => _GlassmorphicScaffoldState();
}
class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
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<Color> _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<Color> 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<double> animation;
final int particleCount;
final List<Particle> 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<double> 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,
});
}

View File

@@ -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>? 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<AnimatedGlassmorphismCard> createState() =>
_AnimatedGlassmorphismCardState();
}
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _blurAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_blurAnimation = Tween<double>(
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,
),
);
},
),
);
}
}

View File

@@ -7,7 +7,7 @@ import '../widgets/native_ad_widget.dart';
import '../widgets/main_summary_card.dart'; import '../widgets/main_summary_card.dart';
import '../widgets/subscription_list_widget.dart'; import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart'; import '../widgets/empty_state_widget.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class HomeContent extends StatelessWidget { class HomeContent extends StatelessWidget {
@@ -35,9 +35,11 @@ class HomeContent extends StatelessWidget {
final provider = context.watch<SubscriptionProvider>(); final provider = context.watch<SubscriptionProvider>();
if (provider.isLoading) { if (provider.isLoading) {
return const Center( return Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)), valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
), ),
); );
} }
@@ -65,7 +67,7 @@ class HomeContent extends StatelessWidget {
onRefresh: () async { onRefresh: () async {
await provider.refreshSubscriptions(); await provider.refreshSubscriptions();
}, },
color: const Color(0xFF3B82F6), color: Theme.of(context).colorScheme.primary,
child: CustomScrollView( child: CustomScrollView(
controller: scrollController, controller: scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
@@ -109,7 +111,7 @@ class HomeContent extends StatelessWidget {
child: Text( child: Text(
AppLocalizations.of(context).mySubscriptions, AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith( 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( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.subscriptionCount(provider.subscriptions.length), .subscriptionCount(provider.subscriptions.length),
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.primaryColor, color: Theme.of(context).colorScheme.primary,
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon( Icon(
Icons.arrow_forward_ios, Icons.arrow_forward_ios,
size: 14, size: 14,
color: AppColors.primaryColor, color: Theme.of(context).colorScheme.primary,
), ),
], ],
), ),

View File

@@ -4,9 +4,8 @@ import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import '../theme/app_colors.dart';
import 'animated_wave_background.dart'; import 'animated_wave_background.dart';
import 'glassmorphism_card.dart'; // Glass 제거: Material 3 Card 사용
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
/// 메인 화면 상단에 표시되는 요약 카드 위젯 /// 메인 화면 상단에 표시되는 요약 카드 위젯
@@ -43,20 +42,16 @@ class MainScreenSummaryCard extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12), padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
child: RepaintBoundary( child: RepaintBoundary(
child: GlassmorphismCard( child: Card(
borderRadius: 16, elevation: 3,
blur: 15, shape: RoundedRectangleBorder(
backgroundColor: AppColors.glassCard, borderRadius: BorderRadius.circular(24),
gradient: LinearGradient( side: BorderSide(
begin: Alignment.topLeft, color: Theme.of(context)
end: Alignment.bottomRight, .colorScheme
colors: AppColors.mainGradient .outline
.map((color) => color.withValues(alpha: 0.2)) .withValues(alpha: 0.5),
.toList(), ),
),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
), ),
child: Container( child: Container(
width: double.infinity, width: double.infinity,
@@ -66,7 +61,6 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
color: Colors.transparent,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@@ -91,9 +85,9 @@ class MainScreenSummaryCard extends StatelessWidget {
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.monthlyTotalSubscriptionCost, .monthlyTotalSubscriptionCost,
style: const TextStyle( style: TextStyle(
color: AppColors color:
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 Theme.of(context).colorScheme.onSurface,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -113,11 +107,17 @@ class MainScreenSummaryCard extends StatelessWidget {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE5F2FF), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: borderRadius:
BorderRadius.circular(4), BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: const Color(0xFFBFDBFE), color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
@@ -125,10 +125,12 @@ class MainScreenSummaryCard extends StatelessWidget {
AppLocalizations.of(context) AppLocalizations.of(context)
.exchangeRateDisplay .exchangeRateDisplay
.replaceAll('@', snapshot.data!), .replaceAll('@', snapshot.data!),
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6), color: Theme.of(context)
.colorScheme
.primary,
), ),
), ),
); );
@@ -160,6 +162,18 @@ class MainScreenSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.baseline, crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic, textBaseline: TextBaseline.alphabetic,
children: [ children: [
// 통화 기호를 숫자 앞에 표시
Text(
currencySymbol,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Text( Text(
NumberFormat.currency( NumberFormat.currency(
locale: defaultCurrency == 'KRW' locale: defaultCurrency == 'KRW'
@@ -172,22 +186,15 @@ class MainScreenSummaryCard extends StatelessWidget {
symbol: '', symbol: '',
decimalDigits: decimals, decimalDigits: decimals,
).format(monthlyCost), ).format(monthlyCost),
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy, color: Theme.of(context)
.colorScheme
.onSurface,
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: -1, 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( padding: const EdgeInsets.symmetric(
vertical: 10, horizontal: 14), vertical: 10, horizontal: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: Theme.of(context)
colors: [ .colorScheme
Colors.white.withValues(alpha: 0.2), .surfaceContainerHighest
Colors.white.withValues(alpha: 0.15), .withValues(alpha: 0.4),
],
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.primaryColor color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
width: 1, width: 1,
), ),
@@ -267,15 +274,17 @@ class MainScreenSummaryCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: Theme.of(context)
Colors.white.withValues(alpha: 0.25), .colorScheme
.primary
.withValues(alpha: 0.12),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: Icon(
Icons.local_offer_rounded, Icons.local_offer_rounded,
size: 14, size: 14,
color: AppColors color:
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘 Theme.of(context).colorScheme.primary,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@@ -286,9 +295,10 @@ class MainScreenSummaryCard extends StatelessWidget {
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.eventDiscountActive, .eventDiscountActive,
style: const TextStyle( style: TextStyle(
color: AppColors color: Theme.of(context)
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .colorScheme
.onSurface,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -328,16 +338,20 @@ class MainScreenSummaryCard extends StatelessWidget {
symbol: currencySymbol, symbol: currencySymbol,
decimalDigits: decimals, decimalDigits: decimals,
).format(eventSavings), ).format(eventSavings),
style: const TextStyle( style: TextStyle(
color: AppColors.primaryColor, color: Theme.of(context)
.colorScheme
.primary,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text( Text(
' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})', ' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
style: const TextStyle( style: TextStyle(
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -371,7 +385,10 @@ class MainScreenSummaryCard extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassBackground, color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
@@ -379,8 +396,8 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: const TextStyle( style: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -388,8 +405,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, value,
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 color: Theme.of(context).colorScheme.onSurface,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View File

@@ -2,13 +2,17 @@ import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'glassmorphism_card.dart'; import 'dart:async';
// Glass 제거: Material 3 Card 사용
import '../main.dart' show enableAdMob; import '../main.dart' show enableAdMob;
import '../theme/ui_constants.dart';
/// 구글 네이티브 광고 위젯 (AdMob NativeAd) /// 구글 네이티브 광고 위젯 (AdMob NativeAd)
/// SRP에 따라 광고 전용 위젯으로 분리 /// SRP에 따라 광고 전용 위젯으로 분리
class NativeAdWidget extends StatefulWidget { 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 @override
State<NativeAdWidget> createState() => _NativeAdWidgetState(); State<NativeAdWidget> createState() => _NativeAdWidgetState();
@@ -19,6 +23,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
bool _isLoaded = false; bool _isLoaded = false;
String? _error; String? _error;
bool _isAdLoading = false; // 광고 로드 중복 방지 플래그 bool _isAdLoading = false; // 광고 로드 중복 방지 플래그
Timer? _refreshTimer; // 주기적 리프레시 타이머
@override @override
void initState() { void initState() {
@@ -43,24 +48,66 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
return; return;
} }
_nativeAd = NativeAd( try {
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID // 기존 광고 해제 및 상태 초기화
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함 _refreshTimer?.cancel();
request: const AdRequest(), _nativeAd?.dispose();
listener: NativeAdListener( _error = null;
onAdLoaded: (ad) { _isLoaded = false;
setState(() {
_isLoaded = true; _nativeAd = NativeAd(
}); adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
}, // 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다.
onAdFailedToLoad: (ad, error) { nativeTemplateStyle: NativeTemplateStyle(
ad.dispose(); templateType: TemplateType.small,
setState(() { mainBackgroundColor: const Color(0x00000000),
_error = error.message; cornerRadius: 12,
}); ),
}, request: const AdRequest(),
), listener: NativeAdListener(
)..load(); 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 반환 함수 /// 광고 단위 ID 반환 함수
@@ -79,19 +126,25 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
@override @override
void dispose() { void dispose() {
_nativeAd?.dispose(); _nativeAd?.dispose();
_refreshTimer?.cancel();
super.dispose(); super.dispose();
} }
/// 웹용 광고 플레이스홀더 위젯 /// 웹용 광고 플레이스홀더 위젯
Widget _buildWebPlaceholder() { Widget _buildWebPlaceholder() {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: EdgeInsets.symmetric(
child: GlassmorphismCard( horizontal:
borderRadius: 16, widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
blur: 10, vertical: UIConstants.adVerticalPadding,
opacity: 0.1, ),
child: Card(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: Container( child: Container(
height: 80, height: UIConstants.adCardHeight,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
children: [ children: [
@@ -99,13 +152,16 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
width: 64, width: 64,
height: 64, height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[300], color: Theme.of(context)
borderRadius: BorderRadius.circular(8), .colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.zero,
), ),
child: const Center( child: Center(
child: Icon( child: Icon(
Icons.ad_units, Icons.ad_units,
color: Colors.grey, color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 32, size: 32,
), ),
), ),
@@ -120,8 +176,11 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
height: 14, height: 14,
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[300], color: Theme.of(context)
borderRadius: BorderRadius.circular(4), .colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.zero,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -129,8 +188,11 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
height: 10, height: 10,
width: 180, width: 180,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[200], color: Theme.of(context)
borderRadius: BorderRadius.circular(4), .colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.zero,
), ),
), ),
], ],
@@ -140,15 +202,18 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
width: 60, width: 60,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue[100], color: Theme.of(context)
borderRadius: BorderRadius.circular(12), .colorScheme
.primary
.withValues(alpha: 0.15),
borderRadius: BorderRadius.zero,
), ),
child: const Center( child: Center(
child: Text( child: Text(
'광고영역', 'ads',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.blue, color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -179,27 +244,29 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
} }
if (_error != null) { if (_error != null) {
// 광고 로드 실패 시 빈 공간 반환 // 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
return const SizedBox.shrink(); return _buildWebPlaceholder();
} }
if (!_isLoaded) { if (!_isLoaded) {
// 광고 로딩 중 로딩 인디케이터 표시 // 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
return const Padding( return _buildWebPlaceholder();
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
);
} }
// 광고 정상 노출 // 광고 정상 노출
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: EdgeInsets.symmetric(
child: GlassmorphismCard( horizontal:
borderRadius: 16, widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
blur: 10, vertical: UIConstants.adVerticalPadding,
opacity: 0.1, ),
child: Card(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: SizedBox( child: SizedBox(
height: 80, // 네이티브 광고 높이 조정 height: UIConstants.adCardHeight,
child: AdWidget(ad: _nativeAd!), child: AdWidget(ad: _nativeAd!),
), ),
), ),

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
import '../../widgets/themed_text.dart'; import '../../widgets/themed_text.dart';
import '../../widgets/common/buttons/primary_button.dart'; import '../../widgets/common/buttons/primary_button.dart';
import '../../widgets/native_ad_widget.dart'; import '../../widgets/native_ad_widget.dart';
@@ -32,7 +32,7 @@ class ScanInitialWidget extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 24.0), padding: const EdgeInsets.only(bottom: 24.0),
child: ThemedText( child: ThemedText(
errorMessage!, errorMessage!,
color: Colors.red, color: Theme.of(context).colorScheme.error,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -59,7 +59,7 @@ class ScanInitialWidget extends StatelessWidget {
onPressed: onScanPressed, onPressed: onScanPressed,
width: 200, width: 200,
height: 56, height: 56,
backgroundColor: AppColors.primaryColor, backgroundColor: Theme.of(context).colorScheme.primary,
), ),
], ],
), ),

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
import '../../widgets/themed_text.dart'; import '../../widgets/themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -14,8 +14,8 @@ class ScanLoadingWidget extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const CircularProgressIndicator( CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor), color: Theme.of(context).colorScheme.primary,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ThemedText( ThemedText(

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_colors.dart'; // Material colors only
import '../../widgets/themed_text.dart'; import '../../widgets/themed_text.dart';
class ScanProgressWidget extends StatelessWidget { class ScanProgressWidget extends StatelessWidget {
@@ -20,7 +20,10 @@ class ScanProgressWidget extends StatelessWidget {
// 진행 상태 표시 // 진행 상태 표시
LinearProgressIndicator( LinearProgressIndicator(
value: (currentIndex + 1) / totalCount, value: (currentIndex + 1) / totalCount,
backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), backgroundColor: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
), ),

View File

@@ -10,7 +10,6 @@ import '../../widgets/common/form_fields/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart'; import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart'; import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart'; import '../../widgets/native_ad_widget.dart';
import '../../theme/app_colors.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart'; import '../../utils/sms_scan/date_formatter.dart';
import '../../utils/sms_scan/category_icon_mapper.dart'; import '../../utils/sms_scan/category_icon_mapper.dart';
@@ -74,57 +73,50 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 구독 정보 카드 // 구독 정보 카드
ClipRRect( Card(
borderRadius: BorderRadius.circular(16.0), elevation: 1,
child: Container( shape: RoundedRectangleBorder(
width: double.infinity, borderRadius: BorderRadius.circular(16.0),
decoration: BoxDecoration( side: BorderSide(
color: AppColors.glassCard, color: Theme.of(context)
borderRadius: BorderRadius.circular(16.0), .colorScheme
border: Border.all( .outline
color: AppColors.glassBorder, .withValues(alpha: 0.4),
width: 1,
),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 20,
spreadRadius: -5,
offset: Offset(0, 10),
),
],
), ),
child: Column( ),
children: [ child: Column(
// 클릭 가능한 정보 영역 children: [
Material( // 클릭 가능한 정보 영역
color: Colors.transparent, Material(
child: InkWell( color: Colors.transparent,
onTap: _handleCardTap, child: InkWell(
borderRadius: const BorderRadius.only( onTap: _handleCardTap,
topLeft: Radius.circular(16.0), borderRadius: const BorderRadius.only(
topRight: Radius.circular(16.0), topLeft: Radius.circular(16.0),
), topRight: Radius.circular(16.0),
child: Padding( ),
padding: const EdgeInsets.all(16.0), child: Padding(
child: _buildInfoSection(categoryProvider), padding: const EdgeInsets.all(16.0),
), child: _buildInfoSection(categoryProvider),
), ),
), ),
),
// 구분선 // 구분선
Container( Container(
height: 1, height: 1,
color: AppColors.navyGray.withValues(alpha: 0.1), color: Theme.of(context)
), .colorScheme
.onSurfaceVariant
.withValues(alpha: 0.1),
),
// 클릭 불가능한 액션 영역 // 클릭 불가능한 액션 영역
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: _buildActionSection(categoryProvider), child: _buildActionSection(categoryProvider),
), ),
], ],
),
), ),
), ),
], ],
@@ -143,7 +135,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).foundSubscription, AppLocalizations.of(context).foundSubscription,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -152,14 +143,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).serviceName, AppLocalizations.of(context).serviceName,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
widget.subscription.serviceName, widget.subscription.serviceName,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -174,7 +163,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).monthlyCost, AppLocalizations.of(context).monthlyCost,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// 언어별 통화 표시 // 언어별 통화 표시
@@ -189,7 +177,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
snapshot.data ?? '-', snapshot.data ?? '-',
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true,
); );
}, },
), ),
@@ -204,14 +191,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).billingCycle, AppLocalizations.of(context).billingCycle,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
widget.subscription.billingCycle, widget.subscription.billingCycle,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
forceDark: true,
), ),
], ],
), ),
@@ -225,7 +210,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).nextBillingDateLabel, AppLocalizations.of(context).nextBillingDateLabel,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
@@ -236,7 +220,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
), ),
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
forceDark: true,
), ),
], ],
); );
@@ -252,7 +235,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
AppLocalizations.of(context).category, AppLocalizations.of(context).category,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
CategorySelector( CategorySelector(
@@ -261,7 +243,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
widget.selectedCategoryId ?? widget.subscription.category, widget.selectedCategoryId ?? widget.subscription.category,
onChanged: widget.onCategoryChanged, onChanged: widget.onCategoryChanged,
baseColor: _getCategoryColor(categoryProvider), baseColor: _getCategoryColor(categoryProvider),
isGlassmorphism: true,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -270,14 +251,14 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
controller: widget.websiteUrlController, controller: widget.websiteUrlController,
label: AppLocalizations.of(context).websiteUrlAuto, label: AppLocalizations.of(context).websiteUrlAuto,
hintText: AppLocalizations.of(context).websiteUrlHint, hintText: AppLocalizations.of(context).websiteUrlHint,
prefixIcon: const Icon( prefixIcon: Icon(
Icons.language, Icons.language,
color: AppColors.navyGray, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
style: const TextStyle( style: TextStyle(
color: AppColors.darkNavy, color: Theme.of(context).colorScheme.onSurface,
), ),
fillColor: AppColors.pureWhite.withValues(alpha: 0.8), fillColor: Theme.of(context).colorScheme.surface,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),

View File

@@ -5,10 +5,12 @@ import '../providers/category_provider.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import '../utils/billing_date_util.dart';
import 'website_icon.dart'; import 'website_icon.dart';
import 'app_navigator.dart'; import 'app_navigator.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import 'glassmorphism_card.dart'; import '../theme/color_scheme_ext.dart';
// import 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class SubscriptionCard extends StatefulWidget { class SubscriptionCard extends StatefulWidget {
@@ -30,6 +32,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
late AnimationController _hoverController; late AnimationController _hoverController;
bool _isHovering = false; bool _isHovering = false;
String? _displayName; String? _displayName;
static const int _nearBillingThresholdDays = 3;
@override @override
void initState() { void initState() {
@@ -107,6 +110,16 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 과거 날짜인 경우, 다음 결제일 계산 // 과거 날짜인 경우, 다음 결제일 계산
final billingCycle = widget.subscription.billingCycle; 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') { if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') {
@@ -211,26 +224,32 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return AppLocalizations.of(context).paymentInfoNeeded; return AppLocalizations.of(context).paymentInfoNeeded;
} }
// 결제일이 가까운지 확인 (7일 이내) int _daysUntilNextBilling() {
bool _isNearBilling() { final now = DateTime.now();
final text = _getNextBillingText(); final dateOnlyNow = DateTime(now.year, now.month, now.day);
if (text == AppLocalizations.of(context).paymentDueToday) return true; final nbd = widget.subscription.nextBillingDate;
final dateOnlyBilling = DateTime(nbd.year, nbd.month, nbd.day);
final regex = RegExp(r'(\d+)'); if (dateOnlyBilling.isAfter(dateOnlyNow)) {
final match = regex.firstMatch(text); return dateOnlyBilling.difference(dateOnlyNow).inDays;
if (match != null) {
final days = int.parse(match.group(1) ?? '0');
return days <= 7;
} }
return false; final next =
BillingDateUtil.ensureFutureDate(nbd, widget.subscription.billingCycle);
return next.difference(dateOnlyNow).inDays;
}
// 결제일이 가까운지 확인
bool _isNearBilling() {
final days = _daysUntilNextBilling();
return days <= _nearBillingThresholdDays;
} }
// 카테고리별 그라데이션 색상 생성 // 카테고리별 그라데이션 색상 생성
List<Color> _getCategoryGradientColors(BuildContext context) { List<Color> _getCategoryGradientColors(BuildContext context) {
try { try {
if (widget.subscription.categoryId == null) { if (widget.subscription.categoryId == null) {
return AppColors.blueGradient; return [Theme.of(context).colorScheme.primary];
} }
final categoryProvider = context.watch<CategoryProvider>(); final categoryProvider = context.watch<CategoryProvider>();
@@ -238,19 +257,16 @@ class _SubscriptionCardState extends State<SubscriptionCard>
categoryProvider.getCategoryById(widget.subscription.categoryId!); categoryProvider.getCategoryById(widget.subscription.categoryId!);
if (category == null) { if (category == null) {
return AppColors.blueGradient; return [Theme.of(context).colorScheme.primary];
} }
final categoryColor = final categoryColor =
Color(int.parse(category.color.replaceAll('#', '0xFF'))); Color(int.parse(category.color.replaceAll('#', '0xFF')));
return [ return [categoryColor];
categoryColor,
categoryColor.withValues(alpha: 0.8),
];
} catch (e) { } catch (e) {
// 색상 파싱 실패 시 기본 파란색 그라데이션 반환 // 색상 파싱 실패 시 기본 primary 색 반환
return AppColors.blueGradient; return [Theme.of(context).colorScheme.primary];
} }
} }
@@ -296,328 +312,362 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: MouseRegion( child: MouseRegion(
onEnter: (_) => _onHover(true), onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false), onExit: (_) => _onHover(false),
child: AnimatedGlassmorphismCard( child: Card(
padding: EdgeInsets.zero, elevation: _isHovering ? 2 : 1,
borderRadius: 16, shape: RoundedRectangleBorder(
blur: _isHovering ? 15 : 10, borderRadius: BorderRadius.circular(16),
width: double.infinity, // 전체 너비를 차지하도록 설정 side: BorderSide(
onTap: widget.onTap ?? color:
() async { Theme.of(context).colorScheme.outline.withValues(alpha: 0.4),
// ignore: use_build_context_synchronously width: 1,
await AppNavigator.toDetail(context, widget.subscription); ),
}, ),
child: Column( clipBehavior: Clip.antiAlias,
children: [ child: InkWell(
// 그라데이션 상단 바 효과 onTap: widget.onTap ??
AnimatedContainer( () async {
duration: const Duration(milliseconds: 200), // ignore: use_build_context_synchronously
height: 4, await AppNavigator.toDetail(context, widget.subscription);
decoration: BoxDecoration( },
gradient: LinearGradient( child: Column(
colors: widget.subscription.isCurrentlyInEvent children: [
? [ // 그라데이션 상단 바 효과
const Color(0xFFFF6B6B), AnimatedContainer(
const Color(0xFFFF8787), duration: const Duration(milliseconds: 200),
] height: 4,
: isNearBilling // 카테고리 우선: 상단 바는 항상 카테고리 색
? AppColors.amberGradient color: _getCategoryGradientColors(context).first,
: _getCategoryGradientColors(context),
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
), ),
),
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 서비스 아이콘 // 서비스 아이콘
WebsiteIcon( WebsiteIcon(
key: ValueKey( key: ValueKey(
'subscription_icon_${widget.subscription.id}'), 'subscription_icon_${widget.subscription.id}'),
url: widget.subscription.websiteUrl, url: widget.subscription.websiteUrl,
serviceName: widget.subscription.serviceName, serviceName: widget.subscription.serviceName,
size: 48, size: 48,
isHovered: _isHovering, isHovered: _isHovering,
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
// 서비스 정보 // 서비스 정보
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
// 서비스명 // 서비스명
Flexible( Flexible(
child: Text( child: Text(
_displayName ?? _displayName ??
widget.subscription.serviceName, widget.subscription.serviceName,
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 18, fontSize: 18,
color: AppColors color: Theme.of(context)
.darkNavy, // color.md 가이드: 메인 텍스트 .colorScheme
.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
),
// 배지들 // 배지들
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 이벤트 배지 // 이벤트 배지
if (widget if (widget
.subscription.isCurrentlyInEvent) ...[ .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( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
vertical: 3, vertical: 3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color: Theme.of(context)
colors: [ .colorScheme
Color(0xFFFF6B6B), .surface,
Color(0xFFFF8787),
],
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
width: 0.5,
),
), ),
child: Row( child: Text(
mainAxisSize: MainAxisSize.min, 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<String>(
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: [ children: [
const Icon(
Icons.local_offer_rounded,
size: 11,
color: AppColors.pureWhite,
),
const SizedBox(width: 3),
Text( Text(
AppLocalizations.of(context).event, prices[0],
style: const TextStyle( style: TextStyle(
fontSize: 11, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: AppColors.pureWhite, 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,
), ),
), ),
], ],
), );
), } else {
const SizedBox(width: 6), return Text(
], snapshot.data!,
style: TextStyle(
// 결제 주기 배지 fontSize: 16,
Container( fontWeight: FontWeight.w700,
padding: const EdgeInsets.symmetric( color: widget.subscription
horizontal: 8, .isCurrentlyInEvent
vertical: 3, ? Theme.of(context)
), .colorScheme
decoration: BoxDecoration( .error
color: AppColors.surfaceColorAlt, : Theme.of(context)
borderRadius: BorderRadius.circular(12), .colorScheme
border: Border.all( .primary,
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<String>(
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,
),
), ),
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( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 8,
vertical: 2, vertical: 3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF6B6B) color: (isNearBilling
? Theme.of(context)
.colorScheme
.warning
: Theme.of(context)
.colorScheme
.success)
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Icon(
Icons.savings_rounded, isNearBilling
size: 14, ? Icons.access_time_filled_rounded
color: Color(0xFFFF6B6B), : Icons.check_circle_rounded,
size: 12,
color: isNearBilling
? Theme.of(context)
.colorScheme
.warning
: Theme.of(context)
.colorScheme
.success,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
// 이벤트 절약액 표시 (언어별 통화) Text(
FutureBuilder<String>( _getNextBillingText(),
future: CurrencyUtil style: TextStyle(
.formatEventSavingsWithLocale( fontSize: 11,
widget.subscription, fontWeight: FontWeight.w600,
localeProvider.locale.languageCode, 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<String>(
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,
),
),
],
],
),
],
], ],
], ),
), ),
), ],
], ),
), ),
), ],
], ),
), ),
), ),
), ),

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; // Color resolution now relies on Theme ColorScheme.
/// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯 /// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯
class ThemedText extends StatelessWidget { class ThemedText extends StatelessWidget {
@@ -40,28 +40,12 @@ class ThemedText extends StatelessWidget {
bool forceLight = false, bool forceLight = false,
bool forceDark = false, bool forceDark = false,
}) { }) {
if (forceLight) return AppColors.pureWhite; final scheme = Theme.of(context).colorScheme;
if (forceDark) return AppColors.darkNavy; if (forceLight) return scheme.onPrimary; // typically white
if (forceDark) return scheme.onSurface; // dark text in light theme
final brightness = Theme.of(context).brightness; // 기본: 스킴의 onSurface 사용(라이트/다크 자동 대비)
return scheme.onSurface;
// 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용
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<GlassmorphicIndicator>();
return glassmorphic != null;
} }
@override @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<GlassmorphicIndicator>();
}
@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,
),
);
}
}

View File

@@ -5,7 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
@@ -349,11 +349,11 @@ class _WebsiteIconState extends State<WebsiteIcon>
Color _getColorFromName() { Color _getColorFromName() {
final int hash = widget.serviceName.hashCode.abs(); final int hash = widget.serviceName.hashCode.abs();
final List<Color> colors = [ final List<Color> colors = [
AppColors.primaryColor, const Color(0xFF2563EB), // primary
AppColors.successColor, const Color(0xFF22C55E), // success
AppColors.infoColor, const Color(0xFF6366F1), // info
AppColors.warningColor, const Color(0xFFF59E0B), // warning
AppColors.dangerColor, const Color(0xFFF472B6), // accent/danger
]; ];
return colors[hash % colors.length]; return colors[hash % colors.length];
@@ -595,10 +595,10 @@ class _WebsiteIconState extends State<WebsiteIcon>
return Container( return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'), key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceColorAlt, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(widget.size * 0.2), borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all( border: Border.all(
color: AppColors.borderColor, color: Theme.of(context).colorScheme.outline,
width: 0.5, width: 0.5,
), ),
), ),
@@ -607,10 +607,10 @@ class _WebsiteIconState extends State<WebsiteIcon>
return Container( return Container(
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'), key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceColorAlt, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(widget.size * 0.2), borderRadius: BorderRadius.circular(widget.size * 0.2),
border: Border.all( border: Border.all(
color: AppColors.borderColor, color: Theme.of(context).colorScheme.outline,
width: 0.5, width: 0.5,
), ),
), ),
@@ -621,7 +621,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withValues(alpha: 0.7)), Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)),
), ),
), ),
), ),
@@ -661,10 +661,13 @@ class _WebsiteIconState extends State<WebsiteIcon>
: const Duration(milliseconds: 300), : const Duration(milliseconds: 300),
placeholder: (context, url) { placeholder: (context, url) {
if (ReduceMotion.isEnabled(context)) { if (ReduceMotion.isEnabled(context)) {
return Container(color: AppColors.surfaceColorAlt); return Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest);
} }
return Container( return Container(
color: AppColors.surfaceColorAlt, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center( child: Center(
child: SizedBox( child: SizedBox(
width: widget.size * 0.4, width: widget.size * 0.4,
@@ -672,7 +675,10 @@ class _WebsiteIconState extends State<WebsiteIcon>
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withValues(alpha: 0.7)), Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7)),
), ),
), ),
), ),
@@ -712,14 +718,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
return Container( return Container(
key: ValueKey('fallback_${widget.serviceName}_$_uniqueId'), key: ValueKey('fallback_${widget.serviceName}_$_uniqueId'),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: color,
colors: [
color,
color.withValues(alpha: 0.8), // 약 0.8 알파값
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(widget.size * 0.2), borderRadius: BorderRadius.circular(widget.size * 0.2),
), ),
child: Center( child: Center(