feat: adopt material 3 theme and billing adjustments
5
android/app/src/main/res/values-ja/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">デジタル月額管理者</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
5
android/app/src/main/res/values-ko/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">디지털 월세 관리자</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
5
android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">数字月租管理器</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Digital Rent Manager</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 985 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 312 B |
|
Before Width: | Height: | Size: 439 B |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 558 B |
|
Before Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -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": "节省¥@",
|
||||||
|
|||||||
253
doc/color.md
@@ -1,79 +1,208 @@
|
|||||||
# 구독관리 앱 글래스모피어즘 컬러 & 텍스트 컬러 가이드
|
# SubManager 컬러/테마 가이드 v4 (Glass 제거, 완전 Material 3)
|
||||||
|
|
||||||
구독관리 앱에 글래스모피어즘을 적용할 때, **신뢰성, 편안함, 트렌드함**을 모두 잡으면서도 **텍스트 가독성**을 최우선으로 고려한 컬러 팔레트와 활용법을 안내합니다.
|
목표: 글래스모피어즘(반투명/블러/그라데이션)을 전면 제거하고, 전 화면/버튼/팝업을 Material 3(ColorScheme/typography/shape/elevation) 기준으로 재정렬합니다. 버튼 나열 UI를 드롭다운으로 바꾸지 않습니다. 설정 화면에 라이트/다크/시스템 모드 선택 UI를 추가합니다.
|
||||||
|
|
||||||
## 1. 컬러 팔레트 제안
|
## 0) 현재 상태 진단(요약)
|
||||||
|
- 전역 테마: M3 사용 중(`useMaterial3: true`). 라이트/다크/OLED/고대비 테마 존재.
|
||||||
|
- 이슈: `ColorScheme.error`가 핑크(danger)에 매핑 → 오류색으로 부적합(레드 필요).
|
||||||
|
- Glass 사용처 다수(요약/분석/네비/빈상태 등): 반투명+블러+경계. 다크/저성능 장치에서 가독성·성능 저하 가능.
|
||||||
|
- 곳곳의 하드코딩 텍스트 컬러(`AppColors.darkNavy`, `Color(0xFF...)`) 존재 → 다크에서 대비 문제 소지.
|
||||||
|
|
||||||
| 용도 | 컬러명 | Hex 코드 | 설명/느낌 |
|
## 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
@@ -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.
|
||||||
3
ios/Runner/en.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* Localized display name */
|
||||||
|
"CFBundleDisplayName" = "Digital Rent Manager";
|
||||||
|
|
||||||
3
ios/Runner/ja.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* ローカライズされたアプリ表示名 */
|
||||||
|
"CFBundleDisplayName" = "デジタル月額管理者";
|
||||||
|
|
||||||
3
ios/Runner/ko.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* 로컬라이즈된 앱 표시 이름 */
|
||||||
|
"CFBundleDisplayName" = "디지털 월세 관리자";
|
||||||
|
|
||||||
3
ios/Runner/zh.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* 本地化的应用显示名称 */
|
||||||
|
"CFBundleDisplayName" = "数字月租管理器";
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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), // 다크 네이비
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
8
lib/theme/color_scheme_ext.dart
Normal 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
|
||||||
|
}
|
||||||
7
lib/theme/ui_constants.dart
Normal 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
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드
|
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드
|
||||||
|
|||||||
103
lib/utils/billing_date_util.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/utils/business_day_util.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 컨테이너 확장 애니메이션
|
// 컨테이너 확장 애니메이션
|
||||||
|
|||||||
@@ -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});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
lib/widgets/common/form_fields/currency_dropdown_field.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)} ';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
33
lib/widgets/common/layout/page_container.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [
|
||||||
// 배경 패턴
|
// 배경 패턴
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||