18 Commits

Author SHA1 Message Date
JiWoong Sul
2cd46a303e feat: improve sms scan review and detail layouts 2025-11-14 19:33:32 +09:00
JiWoong Sul
a9f42f6f01 fix: adjust subscription card layout 2025-11-14 17:14:16 +09:00
JiWoong Sul
132ae758de feat: add payment card grouping and analysis 2025-11-14 16:53:41 +09:00
JiWoong Sul
cba7d082bd docs: outline payment card grouping plan 2025-11-14 14:29:36 +09:00
JiWoong Sul
8cec03f181 feat: enhance sms scanner repeat detection 2025-11-14 14:29:32 +09:00
JiWoong Sul
7ace3afaf3 Merge branch 'codex/fix-notification-reliability'
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-19 18:15:36 +09:00
JiWoong Sul
87f82546a4 feat: 알림 재예약 개선과 패키지 업그레이드 2025-09-19 18:10:47 +09:00
JiWoong Sul
e909ba59a4 fix: allow weekend billing dates and restore full-screen alerts 2025-09-19 01:08:09 +09:00
JiWoong Sul
3af9a1f839 fix: ensure notifications use correct channels and dates 2025-09-19 01:06:36 +09:00
JiWoong Sul
44850a53cc feat: adopt material 3 theme and billing adjustments 2025-09-16 14:30:14 +09:00
JiWoong Sul
a01d9092ba docs(pr): summarize notification reliability changes (branch codex/fix-notification-reliability) 2025-09-15 15:38:49 +09:00
JiWoong Sul
3d86316a2b feat(android): add exact alarms permission request entry in Settings\n\n- UI: Settings card shows request when exact alarms not allowed\n- Service: wrap canScheduleExactAlarms/requestExactAlarmsPermission via FLN plugin\n- Keeps changes minimal; no new deps\n\nValidation: scripts/check.sh passed 2025-09-15 15:21:44 +09:00
JiWoong Sul
55e3f67279 fix(notification): improve local notification reliability on iOS/Android\n\n- iOS: set UNUserNotificationCenter delegate and present [.banner,.sound,.badge]\n- Android: create channels on init; use exactAllowWhileIdle; add RECEIVE_BOOT_COMPLETED and SCHEDULE_EXACT_ALARM\n- Dart: ensure iOS present options enabled; fix title variable shadowing\n\nValidation: scripts/check.sh passed (format/analyze/tests)\nRisk: exact alarms require user to allow 'Alarms & reminders' on Android 12+\nRollback: revert manifest perms and switch schedule mode back to inexact 2025-09-15 15:18:45 +09:00
JiWoong Sul
d111b5dd62 fix(sms-permission): re-request on denial and guide permanent denial to app settings
Summary: Improve SMS permission UX so users can request again after denial and are guided to app settings when permanently denied.\nChanges: handle Permission.sms status in controllers, show settings dialog for permanently denied, use kIsWeb guard, context-safety across async.\nValidation: scripts/check.sh passed (analyze/tests OK).\nRisk & Rollback: low; scoped to permission request flow. Revert two controllers if issues.
2025-09-15 11:37:38 +09:00
JiWoong Sul
b944f6967d docs(ads): add AdMob mediation native networks guide with regional strategy and Gradle adapter examples
Summary: Document networks supporting Native ads via AdMob mediation, with regional prioritization, Gradle adapter examples, and setup checklist.\nChanges: adds doc/ads.md.\nValidation: scripts/check.sh passed.\nRisk & Rollback: low; doc-only change. Revert file if needed.
2025-09-15 11:37:32 +09:00
JiWoong Sul
997c2f53a0 feat(assets): 디지털렌트매니저 아이콘(집+체크·스퀴클) PNG 세트 및 생성 스크립트 추가\n\n- 경로: assets/app_icon/house_check/{32..1024}.png\n- 스크립트: scripts/render_icon.py (무의존 PNG 렌더) / scripts/generate_icons.sh
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:37:34 +09:00
JiWoong Sul
79f9aa3eb0 docs: flutter-shadcn-ui 마이그레이션 상세 계획 추가(doc/plan.md)
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:16:09 +09:00
JiWoong Sul
5b72fa196c merge: 'codex/perf-sms-ui-optimizations' 브랜치를 master에 병합
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-10 06:00:47 +09:00
120 changed files with 8638 additions and 4164 deletions

View File

@@ -36,6 +36,7 @@ Sensitive Areas (require explicit approval)
Operational Conventions
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
- Git push 후 공유하는 설명/보고는 반드시 한국어로 작성합니다.
- PR description template:
- Summary: what/why
- Changes: key files and decisions

View File

@@ -45,5 +45,5 @@ flutter {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View File

@@ -1,8 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 재부팅 후 예약 복구를 위해 필요 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- 정확 알람(결제/캘린더 등 정확 시각 필요시) 사용 -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:label="구독 관리"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -13,7 +19,9 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:showWhenLocked="true"
android:turnScreenOn="true">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@@ -36,6 +44,20 @@
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6691216385521068~6638409932" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -29,6 +29,29 @@
"language": "Language",
"notifications": "Notifications",
"appLock": "App Lock",
"paymentCard": "Payment Card",
"paymentCardManagement": "Payment Card Management",
"paymentCardManagementDescription": "Manage saved cards for subscriptions",
"addPaymentCard": "Add Payment Card",
"editPaymentCard": "Edit Payment Card",
"paymentCardIssuer": "Card Name / Issuer",
"paymentCardLast4": "Last 4 Digits",
"paymentCardColor": "Card Color",
"paymentCardIcon": "Card Icon",
"setAsDefaultCard": "Set as default card",
"paymentCardUnassigned": "Unassigned",
"addNewCard": "Add New Card",
"managePaymentCards": "Manage Cards",
"choosePaymentCard": "Choose Payment Card",
"analysisCardFilterLabel": "Filter by payment card",
"analysisCardFilterAll": "All cards",
"cardDefaultBadge": "Default",
"noPaymentCards": "No payment cards saved yet.",
"detectedPaymentCard": "Card Detected",
"detectedPaymentCardDescription": "@ was detected from SMS.",
"addDetectedPaymentCard": "Add Card",
"paymentCardUnassignedWarning": "Without a card selection this subscription will be saved as \"Unassigned\".",
"areYouSure": "Are you sure?",
"notificationPermission": "Notification Permission",
"notificationPermissionDesc": "Permission is required to receive notifications",
"requestPermission": "Request Permission",
@@ -126,9 +149,11 @@
"repeatSubscriptionNotFound": "No repeated subscription information found.",
"newSubscriptionNotFound": "No new subscription SMS found",
"findRepeatSubscriptions": "Find subscriptions paid 2+ times",
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.",
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions.\nService names and amounts can be extracted for easy subscription addition.\nThis auto-detection feature is still under development, and might miss or misidentify some subscriptions.\nPlease review the detected results and add or edit subscriptions manually if needed.",
"startScanning": "Start Scanning",
"foundSubscription": "Found subscription",
"latestSmsMessage": "Latest SMS message",
"smsDetectedDate": "Detected on @",
"serviceName": "Service Name",
"nextBillingDateLabel": "Next Billing Date",
"category": "Category",
@@ -147,6 +172,7 @@
"estimatedAnnualCost": "Estimated Annual Cost",
"totalSubscriptionServices": "Total Subscription Services",
"eventDiscountActive": "Event Discount Active",
"eventDiscountEndsBeforeBilling": "Event discount ends before billing date",
"saving": "Saving",
"paymentDueToday": "Payment Due Today",
"paymentDueInDays": "Payment due in @ days",
@@ -199,7 +225,7 @@
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
"goToCancelPage": "Go to Cancellation Page",
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
"discountPercent": "@% discount",
"discountPercent": "% discount",
"discountAmountWon": "Save ₩@",
"discountAmountDollar": "Save $@",
"discountAmountYen": "Save ¥@",
@@ -259,6 +285,29 @@
"language": "언어",
"notifications": "알림",
"appLock": "앱 잠금",
"paymentCard": "결제수단",
"paymentCardManagement": "결제수단 관리",
"paymentCardManagementDescription": "저장된 결제수단을 추가·편집·삭제합니다",
"addPaymentCard": "결제수단 추가",
"editPaymentCard": "결제수단 수정",
"paymentCardIssuer": "카드 이름 / 발급사",
"paymentCardLast4": "마지막 4자리",
"paymentCardColor": "카드 색상",
"paymentCardIcon": "아이콘",
"setAsDefaultCard": "기본 결제수단으로 설정",
"paymentCardUnassigned": "미지정",
"addNewCard": "새 카드 추가",
"managePaymentCards": "결제수단 관리",
"choosePaymentCard": "결제수단 선택",
"analysisCardFilterLabel": "결제수단별 보기",
"analysisCardFilterAll": "모든 결제수단",
"cardDefaultBadge": "기본",
"noPaymentCards": "등록된 결제수단이 없습니다.",
"detectedPaymentCard": "감지된 결제수단",
"detectedPaymentCardDescription": "SMS에서 @ 이(가) 감지되었습니다.",
"addDetectedPaymentCard": "카드 추가",
"paymentCardUnassignedWarning": "결제수단을 선택하지 않으면 '미지정'으로 저장됩니다.",
"areYouSure": "정말 진행하시겠어요?",
"notificationPermission": "알림 권한",
"notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다",
"requestPermission": "권한 요청",
@@ -356,9 +405,11 @@
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.",
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
"startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다",
"latestSmsMessage": "최신 SMS 메시지",
"smsDetectedDate": "SMS 수신일: @",
"serviceName": "서비스명",
"nextBillingDateLabel": "다음 결제일",
"category": "카테고리",
@@ -377,6 +428,7 @@
"estimatedAnnualCost": "예상 연간 구독 비용",
"totalSubscriptionServices": "총 구독 서비스",
"eventDiscountActive": "이벤트 할인 중",
"eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다",
"saving": "절약",
"paymentDueToday": "오늘 결제 예정",
"paymentDueInDays": "@일 후 결제 예정",
@@ -429,7 +481,7 @@
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
"goToCancelPage": "해지 페이지로 이동",
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
"discountPercent": "@% 할인",
"discountPercent": "% 할인",
"discountAmountWon": "₩@원 절약",
"discountAmountDollar": "$@ 절약",
"discountAmountYen": "¥@ 절약",
@@ -489,6 +541,29 @@
"language": "言語",
"notifications": "通知",
"appLock": "アプリロック",
"paymentCard": "支払いカード",
"paymentCardManagement": "支払いカード管理",
"paymentCardManagementDescription": "保存済みのカードを追加・編集・削除します",
"addPaymentCard": "カードを追加",
"editPaymentCard": "カードを編集",
"paymentCardIssuer": "カード名 / 発行会社",
"paymentCardLast4": "下4桁",
"paymentCardColor": "カードカラー",
"paymentCardIcon": "アイコン",
"setAsDefaultCard": "既定のカードとして設定",
"paymentCardUnassigned": "未設定",
"addNewCard": "新しいカードを追加",
"managePaymentCards": "カードを管理",
"choosePaymentCard": "支払いカードを選択",
"analysisCardFilterLabel": "支払いカード別に表示",
"analysisCardFilterAll": "すべてのカード",
"cardDefaultBadge": "既定",
"noPaymentCards": "登録されたカードがありません。",
"detectedPaymentCard": "検出されたカード",
"detectedPaymentCardDescription": "SMS から @ が検出されました。",
"addDetectedPaymentCard": "カードを追加",
"paymentCardUnassignedWarning": "カードを選択しない場合は「未設定」として保存されます。",
"areYouSure": "よろしいですか?",
"notificationPermission": "通知権限",
"notificationPermissionDesc": "通知を受け取るには権限が必要です",
"requestPermission": "権限をリクエスト",
@@ -586,9 +661,11 @@
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。サービス名と金額を抽出して簡単にサブスクリプションを追加できます。",
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
"startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました",
"latestSmsMessage": "最新のSMSメッセージ",
"smsDetectedDate": "SMS受信日: @",
"serviceName": "サービス名",
"nextBillingDateLabel": "次回請求日",
"category": "カテゴリー",
@@ -607,6 +684,7 @@
"estimatedAnnualCost": "予想年間サブスクリプション費用",
"totalSubscriptionServices": "総サブスクリプションサービス",
"eventDiscountActive": "イベント割引中",
"eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します",
"saving": "節約",
"paymentDueToday": "本日支払い予定",
"paymentDueInDays": "@日後に支払い予定",
@@ -659,7 +737,7 @@
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
"goToCancelPage": "解約ページへ移動",
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
"discountPercent": "@%割引",
"discountPercent": "%割引",
"discountAmountWon": "₩@節約",
"discountAmountDollar": "$@節約",
"discountAmountYen": "¥@節約",
@@ -708,6 +786,29 @@
"language": "语言",
"notifications": "通知",
"appLock": "应用锁定",
"paymentCard": "支付卡",
"paymentCardManagement": "支付卡管理",
"paymentCardManagementDescription": "管理已保存的支付卡(新增/编辑/删除)",
"addPaymentCard": "添加支付卡",
"editPaymentCard": "编辑支付卡",
"paymentCardIssuer": "卡名称/发卡行",
"paymentCardLast4": "后四位",
"paymentCardColor": "卡片颜色",
"paymentCardIcon": "图标",
"setAsDefaultCard": "设为默认卡",
"paymentCardUnassigned": "未指定",
"addNewCard": "新增卡片",
"managePaymentCards": "管理卡片",
"choosePaymentCard": "选择支付卡",
"analysisCardFilterLabel": "按支付卡筛选",
"analysisCardFilterAll": "所有支付卡",
"cardDefaultBadge": "默认",
"noPaymentCards": "尚未保存任何支付卡。",
"detectedPaymentCard": "检测到的支付卡",
"detectedPaymentCardDescription": "短信检测到 @。",
"addDetectedPaymentCard": "添加卡片",
"paymentCardUnassignedWarning": "未选择支付卡时将以\"未指定\"保存。",
"areYouSure": "确定要继续吗?",
"notificationPermission": "通知权限",
"notificationPermissionDesc": "需要权限才能接收通知",
"requestPermission": "请求权限",
@@ -805,9 +906,11 @@
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
"newSubscriptionNotFound": "未找到新订阅短信",
"findRepeatSubscriptions": "查找支付2次以上的订阅",
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。可以提取服务名称和金额,轻松添加订阅。",
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额,轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。",
"startScanning": "开始扫描",
"foundSubscription": "找到订阅",
"latestSmsMessage": "最新短信内容",
"smsDetectedDate": "短信接收日期:@",
"serviceName": "服务名称",
"nextBillingDateLabel": "下次付款日期",
"category": "类别",
@@ -826,6 +929,7 @@
"estimatedAnnualCost": "预计年度订阅费用",
"totalSubscriptionServices": "总订阅服务",
"eventDiscountActive": "活动折扣中",
"eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束",
"saving": "节省",
"paymentDueToday": "今日付款到期",
"paymentDueInDays": "@天后付款到期",
@@ -878,7 +982,7 @@
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
"goToCancelPage": "前往取消页面",
"urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配",
"discountPercent": "@%折扣",
"discountPercent": "%折扣",
"discountAmountWon": "节省₩@",
"discountAmountDollar": "节省$@",
"discountAmountYen": "节省¥@",

123
doc/ads.md Normal file
View File

@@ -0,0 +1,123 @@
# AdMob 미디에이션 네이티브 광고 네트워크 (Android)
아래 네트워크들은 AdMob 미디에이션을 통해 Android에서 네이티브(Native) 광고를 지원합니다. 실제 지원 범위(포맷/통합 방식)는 지역/계정/버전 등에 따라 달라질 수 있으므로 AdMob 콘솔에서 해당 미디에이션 그룹의 포맷 선택 가능 여부로 최종 확인하세요.
## 권장 후보
- Meta Audience Network (FAN)
- 통합: Bidding 전용
- 포맷: Native, Native Banner
- 문서: https://developers.google.com/admob/android/mediation/meta
- InMobi
- 통합: Waterfall(네이티브는 Waterfall만), Bidding(다른 포맷)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/inmobi
- Pangle (ByteDance/TikTok)
- 통합: Bidding + Waterfall
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/pangle
- Mintegral
- 통합: Bidding + Waterfall
- 포맷: Native
- 메모: 네이티브는 “Native (Custom Rendering)” 선택 지침이 있음
- 문서: https://developers.google.com/admob/android/mediation/mintegral
- DT Exchange (Fyber)
- 통합: Waterfall, Bidding(클로즈드 베타)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/dt-exchange
- Moloco
- 통합: Bidding
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/moloco
- ironSource Ads
- 통합: Waterfall(네이티브는 Waterfall만)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/ironsource
- Unity Ads
- 통합: Waterfall, Bidding(오픈 베타)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/unity
- LINE Ads Platform (일본 중심)
- 통합: Bidding(네이티브는 클로즈드 베타)
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/line
- myTarget (RU/CIS 중심)
- 통합: Waterfall
- 포맷: Native
- 문서: https://developers.google.com/admob/android/mediation/mytarget
## 참고 및 주의사항
- 지역성/수요: Pangle(아시아), LINE(일본), myTarget(RU/CIS) 등은 지역별 수요 차이가 큼. 타겟 지역 기준으로 우선순위 구성 권장.
- 통합 방식: 일부는 네이티브가 Waterfall만 지원(InMobi, ironSource), 일부는 Bidding만(Meta), 혼합 지원(Pangle, Mintegral, Unity). 비딩/워터폴 여부에 따라 콘솔 설정이 상이함.
- SDK/어댑터: Android Gradle에 각 네트워크 SDK/어댑터 추가가 필요하며, AdMob UI에서 해당 네트워크를 미디에이션 그룹의 “Native” 포맷으로 매핑해야 함. 개인정보/동의 메시징(US State Privacy, GDPR 등)도 파트너 추가 필요.
- 템플릿/표시: 대부분 Unified Native 기반 에셋을 제공하나 네트워크별 에셋 세트가 달라 `NativeTemplateStyle` 기반 템플릿 레이아웃 조정이 필요할 수 있음.
- AppLovin 유의: 문서상 포맷 표에 Native가 보이더라도 어댑터 변경 이력에 “Native 지원 제거”가 기록되어 있습니다. 실제 지원은 AdMob 콘솔(미디에이션 그룹)에서 포맷 선택 가능 여부로 재확인하세요. 문서: https://developers.google.com/admob/android/mediation/applovin
- Flutter 연동: `google_mobile_ads``NativeAd` 로드/리스너/`AdWidget` 사용 패턴은 동일. 네트워크 추가는 네이티브(Android) 쪽 SDK/어댑터 및 콘솔 설정이 핵심.
## 빠른 적용 체크리스트
- [ ] 타겟 지역에 맞는 네트워크 선정(2~5개)
- [ ] Android 의존성 추가(네트워크 SDK/어댑터)
- [ ] AdMob 콘솔: 미디에이션 그룹 생성(포맷=Native), 각 네트워크 매핑
- [ ] 테스트 모드/테스트 광고 확인(네트워크별 테스트 설정 있음)
- [ ] 앱 내 네이티브 광고 UI 검수(템플릿/에셋 배치, 정책 준수)
---
## 지역별 우선순위 제안(예시)
아래는 일반적인 트래픽·수요 기준의 스타트 세트 예시입니다. 실제 퍼포먼스는 앱 카테고리/유저 페르소나/국가별 규제에 따라 달라질 수 있으므로 A/B로 조합을 검증하세요.
- 한국/일본(KR/JP)
- 1군: Meta(FAN, Bidding) + Pangle(Bidding/Waterfall) + LINE(JP, Bidding/Closed Beta for Native)
- 보강: Mintegral, InMobi, Unity
- 북미/유럽(NA/EU)
- 1군: Meta(FAN) + InMobi + Unity + Chartboost
- 보강: DT Exchange(Fyber), Moloco
- 동남아/인도(SEA/IN)
- 1군: InMobi + Pangle + Mintegral + Meta(FAN)
- 보강: Unity, DT Exchange
- CIS/러시아권
- 1군: myTarget
- 보강: Mintegral, Unity
참고: Chartboost는 네이티브 포맷 지원. 지역/장르에 따라 성과 편차가 있어 NA/EU 게임 카테고리에서 보강용으로 고려.
문서:
- Chartboost: https://developers.google.com/admob/android/mediation/chartboost
---
## Android Gradle 의존성(예시)
Flutter에서 `google_mobile_ads`를 사용해도, 미디에이션 파트너의 Android SDK/어댑터는 Gradle에 직접 추가해야 합니다. 아래 스니펫은 예시이며, “정확한 최신 버전”은 각 네트워크 문서의 Adapter 섹션(Changelog/Artifacts)에서 확인 후 고정하세요.
프로젝트 수준 `settings.gradle`/리포지토리 설정은 기본 `google()`/`mavenCentral()`이면 충분합니다.
`android/app/build.gradle` (dependencies 블록)
```gradle
dependencies {
// Google Mobile Ads SDK (보통 어댑터가 transitive로 끌어오지만 명시해도 무방)
implementation 'com.google.android.gms:play-services-ads:24.6.0' // 최신 권장 버전으로 교체
// Mediation adapters (예시 버전; 실제 최신 버전으로 교체)
implementation 'com.google.ads.mediation:facebook:6.16.0.0' // Meta Audience Network
implementation 'com.google.ads.mediation:pangle:5.5.0.4.0' // Pangle
implementation 'com.google.ads.mediation:mintegral:16.5.91.1' // Mintegral
implementation 'com.google.ads.mediation:inmobi:10.6.3.0' // InMobi
implementation 'com.google.ads.mediation:fyber:8.3.8.0' // DT Exchange(Fyber)
implementation 'com.google.ads.mediation:moloco:3.8.0.0' // Moloco
implementation 'com.google.ads.mediation:ironsource:8.5.0.1' // ironSource
implementation 'com.google.ads.mediation:unity:4.16.0.1' // Unity Ads
implementation 'com.google.ads.mediation:mytarget:5.20.0.0' // myTarget
// implementation 'com.google.ads.mediation:chartboost:<version>' // Chartboost (필요 시)
}
```
버전 확인 팁:
- 각 네트워크 가이드의 “Supported integrations and ad formats”/“Changelog”에서 최소/최신 어댑터 버전 확인
- Maven Central에서 `com.google.ads.mediation:<artifact>` 검색하여 최신 릴리스 확인
- AdMob 콘솔에서 해당 네트워크 추가 시 표시되는 가이드/버전 주석 참조
설정 체크:
- ProGuard/R8 규칙이 필요한 네트워크의 경우 가이드에 명시된 keep 규칙 추가
- COPPA/유럽·미국 주 개인정보법 관련 consent 전달(UMP SDK 또는 자체 메시징) 및 파트너 동기화
- 테스트: 네트워크 콘솔에서 테스트 모드 또는 테스트 디바이스 ID 설정 후 실제 단말에서 `NativeAd` 로드 확인

View File

@@ -1,79 +1,208 @@
# 구독관리 앱 글래스모피어즘 컬러 & 텍스트 컬러 가이드
# SubManager 컬러/테마 가이드 v4 (Glass 제거, 완전 Material 3)
구독관리 앱에 글래스모피어즘을 적용할 때, **신뢰성, 편안함, 트렌드함**을 모두 잡으면서도 **텍스트 가독성**을 최우선으로 고려한 컬러 팔레트와 활용법을 안내합니다.
목표: 글래스모피어즘(반투명/블러/그라데이션)을 전면 제거하고, 전 화면/버튼/팝업을 Material 3(ColorScheme/typography/shape/elevation) 기준으로 재정렬합니다. 버튼 나열 UI를 드롭다운으로 바꾸지 않습니다. 설정 화면에 라이트/다크/시스템 모드 선택 UI를 추가합니다.
## 1. 컬러 팔레트 제안
## 0) 현재 상태 진단(요약)
- 전역 테마: M3 사용 중(`useMaterial3: true`). 라이트/다크/OLED/고대비 테마 존재.
- 이슈: `ColorScheme.error`가 핑크(danger)에 매핑 → 오류색으로 부적합(레드 필요).
- Glass 사용처 다수(요약/분석/네비/빈상태 등): 반투명+블러+경계. 다크/저성능 장치에서 가독성·성능 저하 가능.
- 곳곳의 하드코딩 텍스트 컬러(`AppColors.darkNavy`, `Color(0xFF...)`) 존재 → 다크에서 대비 문제 소지.
| 용도 | 컬러명 | Hex 코드 | 설명/느낌 |
|--------------|--------------|--------------|--------------------------|
| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 |
| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 |
| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 |
| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 |
| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 |
| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 |
| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 |
## 1) 원칙(신뢰·접근성·일관성)
- 신뢰: Primary는 딥 블루(#2563EB). 과장된 장식 대신 명확한 위계/역할색 사용.
- 접근성: 본문 대비 WCAG AA(4.5:1) 충족. on-colors(onPrimary/onSurface/onError…) 일관 적용.
- 일관성: 전역 ColorScheme/typography/shape/elevation 우선, 로컬 styleFrom 최소화.
- 성능/가독성: Glass 제거 → 불투명 Surface + elevation/outline 중심으로 레이어 구분.
## 2. 텍스트 색상 가이드
## 2) 팔레트(최종)
- Primary: #2563EB / onPrimary: #FFFFFF
- Secondary: #60A5FA / onSecondary: #0B1B31(또는 onSurface)
- Tertiary(Info): #6366F1 / onTertiary: #FFFFFF
- Error: #EF4444 / onError: #FFFFFF
- Success: #22C55E / Warning: #F59E0B (둘은 ColorScheme 외 확장 토큰으로 관리)
- Light: Background #F1F5F9 / Surface #FFFFFF / SurfaceVariant #F8FAFC / OnSurface #1E293B / OnSurfaceVariant #334155 / Outline #E2E8F0
- Dark: Background #121212 / Surface #1E1E1E / OnSurface #F5F5F6 / OnSurfaceVariant #94A3B8 / Outline #3F3F46
밝은 배경(예: #f1f5f9, #ffffff(투명)) 위에는 **어두운 텍스트**를,
진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다.
## 3) 타입·라디우스·간격·음영 스케일
- Typography(권장):
- displayLarge 48 / displayMedium 40 / displaySmall 34
- headlineLarge 32 / headlineMedium 28 / headlineSmall 24
- titleLarge 20 / titleMedium 18 / titleSmall 16
- bodyLarge 16 / bodyMedium 14 / bodySmall 12
- labelLarge 14 / labelMedium 12 / labelSmall 11
- Line-height: 1.3~1.5, Letter-spacing: 헤드라인(-0.2~-0.5), 본문(+0.1)
- Shape: 4(칩/태그) / 8(스위치/토글) / 12(버튼/입력) / 16(카드/시트)
- Elevation: 0(평면) / 1(구분) / 3(카드) / 6(상부 시트/다이얼로그)
- Spacing: 4 단위(8/12/16/24/32)로 수직 리듬 고정
| 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 |
|------------------|----------------------|-----------------------------------|
| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 |
| White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 |
| Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 |
| Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 |
| Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 |
| Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 |
## 4) Glass 제거 및 대체 규칙
- `lib/widgets/glassmorphism_card.dart` 사용부 전면 치환:
- 대체: `Card(elevation: 3, color: colorScheme.surface, shape: 16)`
- 경계: `Outline` 기반(라이트 #E2E8F0, 다크 #3F3F46, 투명도 60~80%)
- 섀도우: 라이트만 약하게(8~12), 다크는 outline 위주
- 내부 텍스트: 항상 `colorScheme.onSurface` 또는 전역 `textTheme` 사용(하드코딩 금지)
- 그라데이션/반투명 배경 삭제(필요 시 Hero/그림·아이콘 등으로 시각적 흥미 보완)
## 3. 실전 적용 예시
## 5) 컴포넌트별 가이드(누락 없음)
- AppBar: 배경=surface, 제목/아이콘=onSurface, 높이=56, 타이틀 글꼴=titleLarge
- Navigation(하단): 배경=surface, 활성 아이콘/라벨=primary, 비활성=onSurfaceVariant, 반경=16
- FAB: 배경=primary, 아이콘=onPrimary, 반경=16, elevation=6
- Buttons(Elevated/Text/Outlined): minHeight=48, 반경=12, primary=onPrimary, outline=outline, text=onSurface
- IconButton: 기본 onSurface, 강조 상태는 primary 80~90%
- Inputs(TextField/Selectors): filled 라이트=surfaceVariant, 다크=#2A2A2A, 포커스라인=primary 1.5, 에러=error 1.5~2
- Chips/Badges: 배경=역할색(primary/success/warning/error), 텍스트=onX, 반경=8
- Cards: elevation=3, 반경=16, 배경=surface, 텍스트=onSurface
- Lists/Tiles: 제목=onSurface, 보조=onSurfaceVariant, divider=outline, 타일 반경=12
- Dialogs/Sheets: 배경=surface, 제목=titleLarge, 본문=bodyMedium, 버튼=역할색+onX, elevation=6, 반경=20
- Snackbar: 배경=역할색(primary/success/warning/error), 텍스트/아이콘=onX, 모서리=12, floating
- Tooltips: 배경=onSurface, 텍스트=surface, 반경=8
- Progress: primary 사용, 트랙=onSurfaceVariant
- Charts/Analysis: 팔레트 [primary, tertiary(info), success, warning, error, secondary], 라벨=onSurface
- Categories/SMS: 카테고리 배경 위 텍스트/아이콘은 대비 계산(white 또는 onSurface) 적용
- **배경**: Light Gray (#f1f5f9)
- **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb)
- **메인 텍스트**: Dark Navy (#1e293b)
- **서브/설명 텍스트**: Navy Gray (#334155)
- **버튼 배경**: Deep Blue (#2563eb)
- **버튼 텍스트**: Pure White (#ffffff)
- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6)
## 4. 그라디언트 및 글래스 효과 예시
## 6) 설정 화면에 모드 선택 UI 추가(계획)
- 위치: `lib/screens/settings_screen.dart`
- 섹션명: Appearance(또는 테마)
- 구성: `Theme Mode` 라디오 그룹(시스템 / 라이트 / 다크)
- RadioListTile 3개(버튼 나열 유지, 드롭다운 금지)
- 값: `AppThemeMode.system|light|dark`
- 동작: `context.read<ThemeProvider>().setThemeMode(mode)` 호출
- 추가 토글(유지): 큰 텍스트/모션 감소/고대비(현 Provider 연동)
샘플 코드
```dart
// Flutter 예시 (Dart)
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF2563eb),
Color(0xFF60a5fa),
Color(0xFFe0e7ef),
],
)
final themeProvider = context.read<ThemeProvider>();
Column(children: [
ListTile(title: Text('Theme Mode')),
RadioListTile(
title: Text('System'),
value: AppThemeMode.system,
groupValue: themeProvider.themeMode,
onChanged: (v) => themeProvider.setThemeMode(v!),
),
RadioListTile(
title: Text('Light'),
value: AppThemeMode.light,
groupValue: themeProvider.themeMode,
onChanged: (v) => themeProvider.setThemeMode(v!),
),
RadioListTile(
title: Text('Dark'),
value: AppThemeMode.dark,
groupValue: themeProvider.themeMode,
onChanged: (v) => themeProvider.setThemeMode(v!),
),
]);
```
- 글래스 카드 배경: rgba(255,255,255,0.2) + blur + border(Deep Blue)
- 텍스트: #1e293b(진한 네이비) 또는 #2563eb(딥블루) 사용
## 5. 디자인 팁
## 7) 적용 순서(리스크 최소)
1) 전역 스킴 교정: `ColorScheme.error` 레드로, textTheme onSurface 정렬
2) Glass 제거: `GlassmorphismCard``Card` 치환(화면 단위 PR: 홈→분석→설정→세부)
3) 버튼/입력/스낵바/다이얼로그 on-colors 정렬, 하드코딩 텍스트 제거
4) 모드 선택 UI 추가(설정 화면 라디오 그룹)
5) 카테고리/차트 대비 보정 유틸 적용
6) 회귀·접근성 검증(라이트/다크/시스템)
- **텍스트 대비**를 항상 체크하세요.
밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트!
- **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다.
- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요.
## 8) 검증
- 스크립트: `scripts/check.sh` (format/analyze/test)
- 시각: 모든 화면에서 텍스트 대비(AA) 확인, 상태(Hover/Pressed/Disabled) 점검
- 성능: Glass 제거 후 저사양 단말 스크롤/애니메이션 프레임 확인
## 6. 컬러/텍스트 조합 요약
## 9) 요약
- Glass 제거 + 완전 Material 3 전환으로 신뢰감, 가독성, 성능을 함께 강화합니다.
- 오류색은 레드로 통일, on-colors로 대비를 보장합니다.
- 설정에 시스템/라이트/다크 선택을 제공하고, 버튼 나열 UI는 유지합니다.
| 배경색 | 텍스트색 | 용도 예시 |
|------------------|------------------|--------------------|
| #f1f5f9 | #1e293b | 메인 타이틀, 내용 |
| #ffffff(투명) | #2563eb | 카드 내 강조 |
| #2563eb | #ffffff | 버튼, 반전 강조 |
| #60a5fa | #334155 | 서브, 설명 |
| #38bdf8 | #334155 | 포인트, 서브텍스트 |
## 진행 현황(Work Log)
- [완료] 전역 스킴 교정: `ColorScheme.error`를 레드(#EF4444)로 교정 (라이트/다크)
- [완료] 스낵바 오류색 정렬: `AppSnackBar.showError``colorScheme.error` 사용
- [완료] 설정 화면 테마 모드 UI: System/Light/Dark SegmentedButton 추가(드롭다운/라디오 대체, M3 준수)
- [완료] Glass 제거(설정 화면): `GlassmorphismCard``Card` 치환
- [완료] Glass 제거(빈 상태 위젯): `EmptyStateWidget``Card` 기반으로 재구성
- [완료] Glass 제거(홈 요약 카드): `MainScreenSummaryCard` 외곽 → `Card`
- [완료] Glass 제거(분석 카드): 월간 지출/총지출/파이차트 카드 → `Card`
- [완료] Glass 제거(광고 카드): `NativeAdWidget``Card`
- [완료] Glass 제거(추가 폼 섹션): `AddSubscriptionForm``Card`
- [완료] Glass 제거(SMS 권한 화면): 설명 카드 → `Card`
- [완료] Glass 제거(네비게이션): Floating Navigation Bar → Container + Padding(Material 기준)
- [완료] Glass 제거(메인 스캐폴드): `GlassmorphicScaffold` → Stack+Scaffold(배경 그라디언트+M3)
- [진행] Glass 제거(기타): 일부 카드(예: SubscriptionCard) 잔여 사용처 점진 치환 예정
- [완료] Glass 제거(구독 카드): SubscriptionCard 래퍼를 Material Card+InkWell로 대체
- [진행] 하드코딩 텍스트 컬러 제거: 메인 요약/URL 섹션/네비/홈 로딩 인디케이터 등 onSurface/onSurfaceVariant로 정렬
- [진행] 하드코딩 컬러 정리(추가): 카테고리 관리/앱 잠금/이벤트·URL 상세 섹션 컨테이너와 텍스트를 M3(`surface`, `outline`, `onSurface`)로 정렬
- [진행] 폼/셀렉터 M3 정렬: DatePickerField/CurrencySelector 색을 `onSurface`/`primary`/`surfaceVariant`로 통일
- [진행] Selectors: Category/BillingCycle 선택 컴포넌트의 배경/텍스트를 `primary`/`onSurface`로 정렬
- [진행] 공통 입력/라벨: BaseTextField/DatePickerField 라벨·힌트·값을 `onSurface`/`onSurfaceVariant`로 정렬
- [진행] 삭제 다이얼로그: Glass 제거, Material Dialog(표면/elevation) + on-colors 적용
- [진행] 추가 화면: 이벤트 섹션 타이틀/설명을 onSurface로 정렬
- [진행] 날짜 필드(DatePicker/Range): 라벨/값/아이콘/컨테이너를 M3 surface/outline/onSurface 계열로 치환
- [진행] 분석 카드/리스트: 보조 텍스트/경계/아이콘을 onSurfaceVariant/primary 계열로 정리
- [진행] 설정 화면: 텍스트/아이콘 색을 onSurface/onSurfaceVariant로 정리
- [진행] SMS 권한 화면: 아이콘/제목/본문을 primary/onSurface/onSurfaceVariant로 정리
- [진행] 추가 화면 AppBar/저장 버튼: 색을 onSurface/primary로 정리
- [다음] 버튼/입력/다이얼로그/스낵바의 on-colors 재점검 및 하드코딩 텍스트 컬러 제거
### 2025-09-10 작업 메모(Incremental)
- [완료] Settings 화면: `AppColors.*` 제거 → `colorScheme.primary/onSurface/onSurfaceVariant` 적용. 알림 반복 SwitchListTile의 `activeColor` 비사용(신 API `activeThumbColor/activeTrackColor`)로 교체.
- [완료] AddSubscriptionForm: CurrencySelector / BillingCycleSelector / CategorySelector의 `isGlassmorphism` 플래그 비활성(기본 M3 경량 스타일 사용).
- [완료] MainSummaryCard: 이벤트 절약액 텍스트 색상을 `colorScheme.primary`로 정렬.
- [완료] MonthlyExpenseChartCard: 툴팁 배경/텍스트를 `inverseSurface/onInverseSurface`로 교체(가독성 향상).
- [완료] Light Theme 카드/입력: `lib/theme/app_theme.dart`의 카드 테마에서 글래스 컬러/보더 제거, elevation=1·radius=16 유지. InputDecorationTheme는 `surfaceVariant`(light 대체 토큰) + `outline/primary/error` 경계로 전환.
- [완료] TotalExpenseSummaryCard: 아이콘 캡슐 배경을 `surfaceContainerHighest`+`outline`로 교체, 아이콘 컬러는 `primary` 사용. 복사 스낵바의 글래스 배경 제거.
- [완료] DetailFormSection: 글래스 박스 → `surface` + `outline` 컨테이너로 교체, Currency/BillingCycle/Category 셀렉터의 `isGlassmorphism` 비활성.
- [완료] SMS Scan SubscriptionCard: `Card(elevation:1, outline)`로 교체, forceDark 텍스트 제거, 입력 `fillColor``surface`로 통일, 카테고리 셀렉터 글래스 비활성.
- [완료] SecondaryButton: Hover 배경을 `onSurface` 6%로, 보더/텍스트를 `outline/primary`로 정렬.
- [완료] CategoryManagement: AppBar `primary/onPrimary` 적용, Dropdown `value→initialValue`(비권장 API 해결), 텍스트 onSurface 정렬.
- [완료] Primary/SecondaryButton hover 트랜스폼: `Matrix4.scale` 제거 → `diagonal3Values` 또는 `Transform.scale`로 대체(비권장 API 해결).
- [완료] RotatePageRoute 전환: `Matrix4.scale` 제거 → 중첩 `Transform.scale`로 전환.
- [완료] ThemedText: AppColors 의존 제거, 대비 색상 결정을 `colorScheme.onSurface` 기반으로 단순화.
- [완료] 글래스 파일 제거: `lib/widgets/glassmorphism_card.dart`, `lib/widgets/glassmorphic_scaffold.dart` 삭제(미참조 확인).
- [완료] Light Theme 텍스트·컴포넌트 정렬: `app_theme.dart`에서 textTheme를 M3 기본 + `onSurface` 컬러로 일괄 정렬. Switch/Checkbox/Radio/Slider/TabBar/Divider를 `ColorScheme` 기반으로 리팩터.
- [완료] AddSubscriptionAppBar: const 적용(경고 제거), `scripts/check.sh` 전체 통과 확인.
- [완료] Dark/OLED 테마 정리: `adaptive_theme.dart`에서 다크 텍스트·컴포넌트(M3 on-colors) 정렬, Input/Buttons/TabBar/Divider/Switch/Checkbox/Radio/Slider를 ColorScheme 기준으로 통일. OLED는 surface/배경만 블랙 톤으로 보정.
- [완료] ThemedText: Glass 마커 제거(Indicator/Wrapper 삭제), 대비 로직 단순화.
- [완료] Charts: 월간 바차트 색상 `ColorScheme.primary/secondary`로 전환, 그리드/백바 `onSurfaceVariant` 사용. 파이차트 팔레트는 `ColorScheme(primary/secondary/tertiary/error)+success/warning 상수`로 정리.
- [완료] Settings/SubscriptionCard: 글래스 위젯 의존 제거 → Material Card + InkWell로 치환(중첩 Padding은 ListTile의 `contentPadding` 사용).
- [완료] Settings 색 정리 마무리: 모든 텍스트/아이콘/보더/드롭다운을 `onSurface/onSurfaceVariant/primary/surface`로 통일.
- [완료] 전역 그라데이션 제거: EmptyState/FloatingNav Add/MainSummary 이벤트 배지/Detail Header/Detail 편집 안내/SubscriptionCard 헤더·이벤트 배지/Add 화면 헤더/Splash 배경, 로고/파티클 장식 등 모든 Linear/Radial gradient 삭제. 단색은 `primary`/`surface`/`surfaceContainer*`/semantic(error, warning)로 대체.
- [완료] 차트 막대 그라데이션 제거: 단색 `primary`로 통일.
- [검증] `scripts/check.sh` 실행: 포맷 자동 적용 후 정적 분석 info 수준 경고만 존재(주요 `activeColor` 비권장 항목 해결됨).
## 결론
### 2025-09-11 작업 메모(Incremental)
- [완료] BillingCycleSelector: 선택 배경=primary, 텍스트=onPrimary, 비선택 배경=surface, 보더=outline(60%); glass/gradient 파라미터는 비사용 처리(호환 유지).
- [완료] CurrencySelector: 동일한 M3 패턴으로 정리(표면/윤곽선/온컬러), isGlassmorphism 무시.
- [완료] CategorySelector: 선택 시 baseColor가 있으면 사용, 없으면 primary; 나머지는 surface/outline/onSurface.
- [완료] AnalysisBadge: AppColors 제거 → surface 배경 + outline 보더 + 은은한 블랙 섀도(8%).
- [완료] SubscriptionCard:
- 상단 스트립: event=error, 결제 임박=warning, 그 외=카테고리 색.
- 가격: 이벤트 원가=onSurfaceVariant 취소선, 현재가=error, 일반가=primary.
- 결제 예정 뱃지: success/warning(확장 토큰) 사용, 배경은 10% 알파.
- 결제 주기 뱃지: surface + outline, 텍스트 onSurfaceVariant.
- [검증] `scripts/check.sh` 전체 통과(Format/Analyze/Test OK).
- **블루+화이트+민트** 조합과, **밝은 배경+어두운 텍스트** 원칙으로 신뢰성, 편안함, 트렌드함, 가독성 모두 챙길 수 있습니다.
- 실제 앱에 적용할 때는 위 표를 참고해 각 상황별로 텍스트 컬러를 꼭 맞춰주세요.
- 글래스모피어즘 효과와 대비 높은 텍스트 조합으로, 세련되고 사용성 좋은 구독관리 앱을 완성할 수 있습니다.
### 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` 재실행 통과.

122
doc/payment_card_plan.md Normal file
View File

@@ -0,0 +1,122 @@
# 결제수단 구분 확장 계획
## 배경
- 현재 홈 화면은 카테고리별 구독 목록만 제공하며, 결제 카드 기준으로 필터링하거나 시각적으로 구분할 수 없음.
- 사용자 요청: 카드 회사명과 마지막 4자리로 구독을 분류해 데이터/UX 양쪽 모두에서 카드별 인사이트를 제공.
## 목표
- 구독 데이터를 카드 단위로 매핑할 수 있는 스키마 확장.
- 카드 정보를 한 번만 등록하도록 관리 화면을 제공해 재사용성 확보.
- 홈 화면에서 카테고리/카드 뷰를 토글하며, 헤더·리스트·분석 카드가 카드 정보를 시각적으로 노출.
- 모든 변경은 기존 카테고리 UX를 유지하면서 점진적 도입이 가능해야 함.
## 작업 체크리스트
1. [x] `SubscriptionModel``paymentCardId` 추가, `PaymentCardModel`/`PaymentCardProvider` 구현, Hive 등록 및 마이그레이션 수행.
2. [x] 결제수단 관리 화면과 구독 추가/편집/SMS 시트에서 사용할 `PaymentCardSelector` + “새 카드 추가” 플로우 구현.
3. [x] 홈·리스트 UI에 카테고리/카드 토글, 그룹 헤더, 카드 Chip 표시, `SubscriptionGroupingHelper` 도입.
4. [x] 구독 상세 화면(헤더, 결제 정보 섹션, 편집 폼)에서 카드 정보 노출 및 수정 기능 연결.
5. [x] SMS 스캔 컨트롤러/리뷰 UI에 카드 추정·선택·저장 로직 추가, 기본값/자동 생성 전략 반영.
6. [x] 분석 화면(파이 차트, 합계 카드, 월별 차트 등)이 카드 필터/데이터에 대응하도록 확장.
7. [x] 설정/내비게이션/로컬라이제이션/접근성 업데이트 및 새 문자열 번역 반영.
8. [ ] QA 플로우: `flutter pub run build_runner build`, `scripts/check.sh`, 다국어·다크모드·태블릿·알림/백업 테스트 완료.
## 데이터 모델 및 저장소
- `SubscriptionModel``paymentCardId`(필요 시 `displayName`/닉네임) 필드 추가, Hive 어댑터 재생성. 기존 데이터는 `null` 기본값으로 역호환.
-`PaymentCardModel` 작성: `id`, `issuerName`(회사명 자유 입력), `last4`, `colorHex`, `iconName` 등을 저장. Hive typeId는 미사용 값을 배정.
- `PaymentCardProvider`에서 Hive box(`payment_cards`)를 관리하고 CRUD, 정렬, 기본값 선택 기능 제공.
- `main.dart` 초기화 시 카드 어댑터 등록 → Provider 주입.
- 구독 저장 로직(`SubscriptionProvider.add/update`)과 SMS/수동 추가 컨트롤러에서 `paymentCardId`를 인자로 전달.
## 카드 정보 입력 UX
- 전용 관리 화면: 설정 > “결제수단 관리” 또는 독립 `PaymentCardManagementScreen`.
- 필수 입력: 회사명(자유 텍스트), 마지막 4자리(숫자 4자리), 선택형 색상/아이콘.
- 리스트 정렬, 편집, 삭제, 기본 카드 지정, 구독 수 연동 배지 표시.
- 컨텍스트 내 빠른 등록: 구독 추가/수정 폼, SMS 스캔 리뷰 화면 등에서 “+ 새 카드” 버튼을 눌렀을 때 시트/모달로 간단 등록 가능.
- 구독 추가/수정 폼에 `PaymentCardSelector`를 추가:
- 드롭다운/검색형 목록에 등록된 카드를 노출하고, 최근 사용 카드가 상단에 정렬되도록 UX 최적화.
- 카드 ID가 비어 있으면 “미지정” 상태로 저장해 기존 UX 유지.
- UX 권장안: **설정 화면**에서 카드 풀을 미리 관리하되, **컨텍스트 모달**로도 등록할 수 있게 하여 흐름을 끊지 않음. 단순한 “옵션” 스위치에 카드 정보를 묻는 것보다 입력 목적이 명확하고 재사용성이 높음.
## 홈 화면 및 리스트 UI
- `HomeContent`를 상태형으로 전환하고 `enum SubscriptionGrouping { category, paymentCard }`를 유지. 선택 상태는 `SharedPreferences` 등으로 로컬 저장.
- “내 구독” 헤더 오른쪽에 SegmentedButton/ChoiceChip으로 카테고리↔카드 토글을 제공.
- `SubscriptionListWidget`을 범용 그룹 리스트로 확장:
- 그룹 메타데이터(타이틀, 통화 합계, 색상, 서브텍스트)를 받아 헤더 구성.
- 카드 모드에서는 회사명 + `****1234`, 카드 색상 배지, 카드별 통화 합계를 노출.
- 개별 구독 카드(`SubscriptionCard`) 상단에 결제수단 Chip을 추가해 어떤 카드에 속했는지 즉시 파악 가능.
## 구독 상세 화면 반영
- `DetailScreen` 상단 요약 카드에 결제수단 Chip/배지와 카드 색상을 노출.
- “결제 정보” 섹션에 “결제수단” 행을 추가해 회사명 + `****1234`, 카드별 메모 등을 보여줌.
- 상세 화면의 편집 아이콘 → 편집 시트로 진입 시 현재 `paymentCardId`를 기본 선택하여 사용자가 쉽게 변경할 수 있게 함.
- 카드 Chip을 탭하면 카드 관리 화면으로 이동하거나 빠른 편집 시트를 띄워 카드 명칭/색상 수정이 가능하도록 연동.
## SMS 스캔 흐름 적용
- `SmsScanController`가 생성한 임시 구독 모델에도 `paymentCardId` 필드를 포함.
- 스캔 결과 리뷰 리스트에서 각 구독 옆에 카드 선택 드롭다운을 노출:
- 기본값은 (1) 동일 발급사를 과거에 사용한 기록이 있으면 해당 카드, (2) 지정된 기본 카드, (3) “미지정” 순으로 결정.
- 다중 선택을 빠르게 하기 위해 스와이프/컨텍스트 메뉴 대신 인라인 세그먼트나 바텀 시트를 사용.
- “모두 저장” 시 선택된 카드 ID를 `SubscriptionProvider.addSubscription` 호출에 전달.
- SMS 패턴으로 카드사를 추정할 수 있는 경우(문구에 “KB국민카드 ****1234” 등)라면 자동으로 새 카드 템플릿을 제안하고, 사용자 확인 후 생성하도록 선택지를 제공.
## 화면/플로우별 변경 영향 (릴리스 전 점검)
### 홈/목록/위젯
- `HomeContent`, `SubscriptionListWidget`, `CategoryHeaderWidget`, `SubscriptionCard`, `NativeAdWidget` 인접 간격 등 모든 위젯이 새로운 그룹 메타데이터를 받아도 레이아웃이 깨지지 않는지 확인.
- 카드 모드에서 스켈레톤/EmptyState/애니메이션이 그대로 작동하는지, 그리고 `RefreshIndicator`·무한 스크롤이 정상인지 검증.
- 다국어(`en/ko/ja/zh`)에서 카드명/`****1234` 조합이 줄바꿈되지 않도록 최소/최대 길이 처리.
### 구독 추가/편집/상세
- `AddSubscriptionController`, `DetailScreenController`의 상태/검증 로직에 `paymentCardId`가 포함되었는지 확인.
- 저장/취소/변경 이벤트에서 카드 ID가 누락될 경우 기본값 처리.
- 이벤트/할인 섹션, URL 섹션 등 기존 위젯과 상호작용 시 포커스 이동·폼 검증이 동일하게 작동하는지 QA.
- 상세 화면 헤더/폼/아코디언 등 모든 서브 위젯(`detail_*`)이 카드 배지를 수용하도록 패딩 보정.
### SMS 스캔 및 자동 감지
- `SmsScanController`, `SmsScanner`, `SubscriptionConverter` 등 데이터 파이프라인에 카드 메타 추가.
- 스캔 결과 UI(선택 리스트, 확정 다이얼로그, Snackbar)에서 카드가 선택되지 않았을 때 경고/기본값 표시를 명확히 함.
- 자동 감지 카드 생성 로직은 사용자 최종 확인 후만 저장되도록 하고, 잘못된 카드 추론 시 수정 경로를 안내.
### 분석/대시보드
- `AnalysisScreen`, `SubscriptionPieChartCard`, `TotalExpenseSummaryCard`, `MonthlyExpenseChartCard`, `EventAnalysisCard`가 카드 모드 전환에 따른 필터/데이터세트 변경을 감지하는지 확인.
- 향후 카드별 하이라이트를 추가할 경우를 대비해 `SubscriptionGroupingHelper` 출력 구조가 확장 가능한지 검토.
### 설정/관리/내비게이션
- `SettingsScreen` 내 새 “결제수단 관리” 항목 및 `PaymentCardManagementScreen`이 탐색 스택/앱 잠금 흐름에 맞게 라우팅되는지 확인.
- `NavigationProvider``FloatingNavigationBar` 상태와 충돌하지 않는지 QA.
### 데이터/싱크/백업
- Hive 박스 버전이 증가한 뒤에도 기존 사용자 데이터(베타/QA) 로딩에 문제가 없는지 실제 마이그레이션 테스트.
- `SubscriptionProvider.refreshSubscriptions`, `notificationProvider`, `ExchangeRateService` 등 구독 컬렉션을 사용하는 모든 클래스에서 `paymentCardId`를 읽고 무시해도 예외가 발생하지 않는지 확인.
- 테스트 데이터(`lib/temp/test_sms_data.dart`, demo seed)에도 카드 필드가 포함되었는지 점검.
### 로컬라이제이션/접근성
- `AppLocalizations`, `intl` 메시지에 결제수단 관련 텍스트(“결제수단”, “카드 관리”, 오류 메시지 등)를 추가하고 4개 언어 번역을 준비.
- 스크린리더(VoiceOver/TalkBack)에서 카드 정보가 올바른 순서로 읽히는지, Chip 탭 시 라벨이 명확한지 확인.
- 컬러 배지 대비가 Material 3 접근성 가이드라인(대비 3:1 이상)을 만족하도록 색상 선택 UI/프리셋을 검토.
### QA 체크리스트
1. 새 카드 생성 → 구독 추가/편집/상세/SMS 스캔 → 삭제까지 전 과정에서 데이터 일관성 확인.
2. 카드 토글이 유지되는지(앱 재시작 포함) 확인.
3. `scripts/check.sh` + `flutter pub run build_runner build --delete-conflicting-outputs` 실행 후 경고 없는지 확인.
4. 다국어·다크모드·태블릿 해상도에서 UI 붕괴 여부 점검.
5. 알림/위젯/백그라운드 서비스(예: 결제 알림)에서 카드 필드 추가로 인한 크래시가 없는지 Crashlytics/디버그 로그 확인.
#### QA 실행 현황 (2025-11-14)
-`flutter pub run build_runner build --delete-conflicting-outputs`
-`scripts/check.sh`
-`flutter analyze`
-`flutter test`
## 분석 및 향후 확장
- 공통 `SubscriptionGroupingHelper`를 만들어 카드/카테고리 그룹 데이터를 모두 생성하게 설계하면 `MonthlyExpenseChartCard`, 파이 차트, 이벤트 카드 등도 카드 필터를 쉽게 지원.
- 초기에는 홈 리스트에만 카드 모드를 적용하고, 이후 분석 탭에 “카드별 지출” 섹션을 추가해 순차 배포.
## 검증/운영
- 모든 변경 후 `scripts/check.sh`로 포맷(`dart format`), 정적 분석(`flutter analyze`), 테스트(`flutter test`)를 실행.
- Hive 스키마가 증가하므로 `flutter pub run build_runner build --delete-conflicting-outputs`를 통해 어댑터 재생성.
- UI 변경 시 기본/카드 모드 스크린샷을 확보해 QA 공유.
## 리스크 및 완화
- **Hive 마이그레이션**: 새 필드는 optional로 두고 기본값을 유지해 앱 크래시를 방지. 배포 전 베타 빌드로 데이터 검증.
- **사용자 혼란**: 토글 기본값을 기존 “카테고리”로 유지하고, 첫 진입 시 간단한 스낵바/tooltip으로 카드 뷰를 안내.
- **데이터 입력 번거로움**: 관리 화면에서 최소 필드만 요구하고, 구독 폼에서 바로 생성할 수 있게 동선 축소.

113
doc/plan.md Normal file
View File

@@ -0,0 +1,113 @@
# SubManager UI 리디자인/리팩터링 계획 (flutter-shadcn-ui 기반)
## 개요
- 목적: 앱 전반 UI를 `flutter-shadcn-ui`로 표준화하고, 라이트/다크 테마와 의미 있는 컬러 체계를 구축. 사용하지 않는 코드/파일 정리, 복잡한 알고리즘을 동등 효과의 단순한 구현으로 교체.
- 원칙: 색채심리학/게슈탈트 심리학/피츠의 법칙/마이크로인터랙션을 반영. 화면 간 일관성 유지. 사이드 이펙트 최소화(동작/데이터 모델 변경 없이 UI 중심).
- 범위: `lib/screens`, `lib/widgets`, `lib/theme` 전반. 일부 `services/*` 단순화 대상 포함(동일 기능 유지).
## 사전 승인 필요(착수 전)
1) 의존성 추가: `flutter_shadcn_ui` (pubspec.yaml).
2) 테마 구조 재구성: 기존 `app_theme.dart`, `app_colors.dart` → shadcn 토큰/스케일 중심으로 정리.
3) 불용 파일 삭제: 기존 커스텀 위젯·스타일(예: 글라스모피즘 계열 등) 제거.
4) 점진적 마이그레이션 방식 선택(권장) 또는 일괄 치환(위험도 높음).
## 접근 전략(옵션)
- 옵션 A: 점진적 이행(권장)
1단계 토대(테마/토큰/기본 컴포넌트) → 2단계 주요 화면 치환 → 3단계 잔여 위젯/정리. 리스크 낮고 롤백 용이.
- 옵션 B: 일괄 치환
모든 화면/컴포넌트를 한 번에 교체. 속도는 빠르나 충돌/리스크 큼. 권장하지 않음.
이 계획서는 옵션 A를 기준으로 작성합니다.
## 테마·컬러 설계
- 토큰: primary, secondary, success, warning, danger, info, background, foreground, muted, accent, border, card, popover, ring, overlay.
- 라이트/다크 지원: 동일 의미 색상(semantics)을 양 테마에 매핑. 최소 WCAG 4.5:1 대비.
- 색채심리학 반영(과장 금지, 절제된 사용):
- info: 블루(신뢰/안정),
- success: 그린(완료/안도),
- warning: 앰버(주의 환기),
- danger: 레드(중단/삭제),
- neutral: 슬레이트/징크 계열(콘텐츠 중심).
- 게슈탈트: 시각적 그룹화(카드/섹션/간격 체계), 시선 흐름(타이포·계층), 근접성·유사성 활용.
- 피츠의 법칙: 주요 액션 버튼 터치 타깃 ≥ 44dp, 간격 여유.
- 마이크로인터랙션: 진입/전환 120200ms, 물리 기반 커브, Reduced Motion 설정 반영(`utils/reduce_motion.dart` 유지/연동).
구현 포인트(코드 단계에서 적용):
- `ShadcnTheme` 확장 혹은 테마 브리지 레이어 생성(예: `lib/theme/shadcn_theme.dart`) 후 기존 `ThemeData`와 연결.
- `TextTheme`/`ColorScheme`를 shadcn 토큰으로 역매핑해 타 3rd-party 위젯과도 일관성 유지.
## 컴포넌트 매핑(현행 → shadcn)
- 버튼: `common/buttons/(primary|secondary)_button.dart``Button(variant: primary/secondary)`
- 카드: 다수의 카드형 위젯 → `Card` + `CardHeader/Content/Footer`
- 다이얼로그: `dialogs/*``Dialog`/`AlertDialog` + 의미 색상(위험=red)
- 스낵바: `app_snackbar.dart``Toast` 또는 `Inline Alert`(상황별)
- 입력: `base_text_field.dart`, `currency_input_field.dart`, `date_picker_field.dart`, `selector`류 → `Input`, `Select`, `Popover+Calendar`(날짜)
- 네비게이션: `floating_navigation_bar.dart` → shadcn 스타일 버튼/탭/세그먼트 조합(기능은 Navigator 유지)
- 리스트/아이템: `subscription_*_card(_widget).dart``Card`+`List` 조합, 의미 색상 배지 사용
- 배지/상태: `analysis_badge.dart``Badge`(success/warning/info)
차트는 기존 라이브러리 유지, `Card`/토큰 색상만 적용.
## 화면별 리디자인 가이드
- 메인(`main_screen.dart`): 상단 요약(카드), 탭형 네비게이션, FAB 대신 우선작업 배치(피츠 법칙 반영).
- 구독 추가(`add_subscription_screen.dart`): 단계적 폼(섹션 카드), 필수/보조 액션 분리, 에러/힌트 색상 표준화.
- 상세(`detail_screen.dart`): 정보/행동 분리, 위험 액션은 `danger` 톤, url 영역은 `info` 톤.
- 분석(`analysis_screen.dart`): KPI 카드 3열(태블릿), 1열(폰), 차트 색상은 의미 기반 팔레트.
- 카테고리 관리(`category_management_screen.dart`): 리스트+인라인 편집, 확인/취소 분리, 경고 색상 남용 금지.
- 설정(`settings_screen.dart`): 토글/셀리스트 일관, 테마 전환 즉시 반영, 접근성 강조.
- SMS 권한(`sms_permission_screen.dart`): 단일 초점 화면, primary 호출-행동 버튼 + 보조 링크.
- 스플래시/잠금: 단순한 브랜드/배경, 과도한 애니메이션 제거.
## 정리/삭제 대상(마이그레이션 완료 후)
- 강한 시각효과 위젯: `animated_wave_background.dart`, `glassmorphism_card.dart`, `glassmorphic_scaffold.dart`
- 중복/대체 가능: 커스텀 버튼/카드/스낵바/다이얼로그 구현체(치환 완료 후)
- 사용되지 않는 유틸: 실사용 참조 0인 파일 전부
- 임시/백업: 오래된 백업/실험 파일
삭제는 단계별 PR에서 “치환 완료 확인 → 삭제” 순으로 안전하게 진행.
## 알고리즘 단순화(동일 효과 유지)
- SMS 스캔(`services/sms_scanner.dart`): 필터→파서→정규화 단일 파이프라인(순수 함수)로 재구성, 캐시/메모리 최적화 과잉 제거.
- URL 매처(`services/url_matcher/*`): 정규식 테이블 기반 단일 매칭기로 단순화(사전컴파일 RegExp), 서비스 데이터는 레포지토리 1곳에서 주입.
- 환율(`exchange_rate_service.dart`): `CacheManager` TTL 캐시 단일 책임, 만료 시 새로고침. 중복 포맷터/파서 제거.
- 알림(`notification_service.dart`): 스케줄/권한 체크를 단일 파사드로 노출, 내부 분기 축소.
- 성능 유틸(`performance_optimizer.dart`, `memory_manager.dart`): 체감·유지보수 이점 낮은 미세 최적화 제거, 프레임 드랍 유발 가능 애니메이션 단순화.
모든 변경은 퍼블릭 API/데이터 모델을 유지해 사이드 이펙트 방지.
## 테스트/검증
- 스크립트: `scripts/check.sh` 전 단계 실행(포맷/분석/테스트). 기존 deprecation 경고는 별 PR로 정리.
- 위젯/골든 테스트: 핵심 화면(메인/추가/상세/분석/설정) 라이트/다크 2종 캡처 비교.
- 유닛 테스트: URL 매처/환율 캐시/SMS 파이프라인.
- 접근성: 대비·포커스·터치 타깃 수동 점검 체크리스트.
## 작업 단위/PR 계획
1) 토대 구축: 의존성 추가 + 테마 브리지 + 핵심 컴포넌트(Button/Input/Card/Dialog) 도입.
2) 공용 UI 치환: 스낵바/다이얼로그/폼 필드/카드 템플릿 적용.
3) 화면별 리디자인: 메인→추가→상세→분석→설정 순.
4) 불용 코드 삭제: 치환 완료 파일 제거.
5) 알고리즘 단순화: sms/url/환율/알림 순으로 단일화 + 테스트.
6) 마감: 디테일 조정/접근성/성능 점검.
- 브랜치: `codex/feat-shadcn-migration-*` (단계별).
- 커밋: Conventional Commits + 한국어 본문.
- 롤백: 각 단계는 기능 플래그/치환 전후 비교가 쉬운 최소 단위로 유지.
## 위험 및 완화
- 리소스 색상/테마 충돌 → 토큰 브리지로 양방향 매핑, 미호환 위젯은 유지.
- 3rd-party 차트/네이티브 UI → 표면 색/텍스트만 토큰 적용.
- 분석 실패(deprecation) → 별 PR로 API 교체(`activeColor` 등), 마이그레이션과 분리 처리.
## 승인 체크리스트(Yes/No)
- [ ] `flutter_shadcn_ui` 의존성 추가 승인이 필요합니다.
- [ ] 테마 구조(shadcn 토큰 중심) 재구성 승인.
- [ ] 단계별 불용 파일 삭제 승인.
- [ ] 점진적 이행(옵션 A)로 진행 승인.
## 완료 기준(각 단계)
- `scripts/check.sh` 무사 통과(분석 경고 해결 내역은 별 PR 또는 병행).
- 라이트/다크 스냅샷 비교 이상 없음.
- 대상 화면/컴포넌트 치환 100% 및 구식 코드 제거.
---
작성자 메모: 본 계획은 코드 변경 없이 문서만 추가되었습니다. 승인 후 단계별 구현을 진행합니다.

70
doc/plan_color.md Normal file
View File

@@ -0,0 +1,70 @@
# Color & Theme Plan (Material 3)
Goals
- Remove Glassmorphism. Use Material 3 ColorScheme/typography/shape/elevation consistently.
- Ensure light/dark/system modes with accessible contrast; no dark-on-dark text.
- Semantic colors: primary/secondary/info/success/warning/error.
Phases
1) Audit + Baseline
- Inventory AppColors and Glass usages; map to ColorScheme.
- Set `ColorScheme.error=#EF4444` (light/dark) and verify Snackbar uses.
2) Core Components
- Settings: unify to `onSurface/onSurfaceVariant/primary` and fix Switch deprecations.
- Home Summary: surface/elevation + on-colors; badges use surfaceContainer variants.
- Add Subscription: selectors/fields to M3; disable glass flags.
3) Analysis & Lists
- Charts: grid/labels to onSurfaceVariant; tooltips to inverseSurface.
- Event/Detail sections: containers to surface + outline; text/icons to onSurface.
4) Theme & Cleanup
- Refactor `app_theme.dart` to remove glass defaults; prefer ColorScheme-driven themes.
- Replace remaining hard-coded colors (AppColors.*) with scheme; keep gradients sparingly.
- Resolve lints: const constructors, deprecated APIs (activeColor, scale).
Validation
- Run `scripts/check.sh` every change.
- Visual check in light/dark/system; confirm no low-contrast text.
Current Status (2025-09-10)
- Settings screen updated to ColorScheme; Switch deprecations fixed.
- AddSubscription selectors use M3 (glass flags off).
- MainSummaryCard event-savings text = primary.
- Monthly chart tooltips use inverseSurface/onInverseSurface.
- Next: theme/app_theme.dart cleanup; remaining AppColors usages; chart palette alignment.
Current Status (2025-09-11)
- Billing/Currency/Category selectors: use ColorScheme (selected=primary/onPrimary, unselected=surface+outline, text=onSurface). Glass/grad props deprecated and ignored.
- AnalysisBadge: remove AppColors, use surface + outline + subtle shadow.
- SubscriptionCard: header strip uses error/warning/category; price and badges use ColorScheme (error/primary/onSurfaceVariant); due-chip uses success/warning extension; removed hard-coded reds/grays.
- Checks: scripts/check.sh passes (format/analyze/test).
- Next: migrate remaining AppColors usages (detail sections, snackbars, splash), reduce hard-coded Colors in adaptive_theme.dart, optional: revisit success/warning harmonization.
Update (2025-09-11, PM)
- DetailEventSection: replaced green constants with ColorScheme.success; onPrimary for pill text.
- AddSubscriptionEventSection: info boxes use tertiary; removed AppColors.
- SMS Scan widgets: progress/button now use ColorScheme.primary.
- SubscriptionPieChartCard: no AppColors; chart palette uses scheme.success/warning; in-chart labels are white; exchange-rate chip uses primary soft background/border.
- EventAnalysisCard: discount/current price and discount badge use scheme.success/error.
- TotalExpenseSummaryCard: success icon uses scheme.success.
- AppLock/Main screen SnackBars: unified to scheme.error/success with onPrimary text.
- Splash: overlay/particles/title/subtitle/progress use ColorScheme; particle color bound to scheme.primary.
- Checks: scripts/check.sh passes.
Update (2025-09-11, PM-2)
- Dark Theme(adaptive_theme.dart): replaced hard-coded widget colors with ColorScheme-driven values.
- Inputs: fill=surface, borders=outline/primary/error, labels/hints=onSurfaceVariant.
- Buttons/Switch/Checkbox/Radio/Slider/TabBar/Divider: all use scheme tokens.
- AppBar/Card: background=surface, foreground/on-colors from scheme.
- OLED: inherits dark with surface override only.
- Checks: scripts/check.sh passes (no issues).
Update (2025-09-11, PM-3)
- Light Theme(app_theme.dart): AppColors 의존을 ColorScheme 사용으로 축소.
- InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant.
- Buttons/FAB: primary/onPrimary, Outlined side=outline.
- SnackBarTheme: primary/onPrimary.
- Scaffold background 유지(#F1F5F9) — ColorScheme.background 대신 직접 지정.
- Checks: scripts/check.sh passes.

View File

@@ -0,0 +1,43 @@
Summary
- Improve local notification reliability on iOS and Android without adding dependencies.
- Keep diffs minimal: platform tweaks + small service/UI updates.
Changes
- iOS
- ios/Runner/AppDelegate.swift
- Set UNUserNotificationCenter delegate on launch.
- Implement willPresent to show [.banner, .sound, .badge] while in foreground.
- Android
- android/app/src/main/AndroidManifest.xml
- Add RECEIVE_BOOT_COMPLETED for reboot rescheduling by plugin.
- Add SCHEDULE_EXACT_ALARM to allow exact timing on Android 12+.
- lib/services/notification_service.dart
- Create Android channels on init (subscription_channel, expiration_channel).
- Use AndroidScheduleMode.exactAllowWhileIdle for scheduled notifications.
- Ensure iOS DarwinNotificationDetails always present alert/sound/badge.
- Fix local variable overshadowing method parameter (title).
- Add canScheduleExactAlarms()/requestExactAlarmsPermission() wrappers.
- lib/screens/settings_screen.dart
- Add UI entry to request exact alarms permission when not granted (Android 12+).
Validation
- Ran scripts/check.sh
- Formatting check: OK
- flutter analyze: No issues
- flutter test: All tests passed
- Manual assertions
- Foreground iOS notifications display banners/sounds.
- Android channels created proactively to avoid muted/low-importance.
Risk & Rollback
- Risk
- Android 12+: Exact alarms require user approval in Settings > Special access > Alarms & reminders.
- Slight battery impact from exact alarms.
- Rollback
- Remove SCHEDULE_EXACT_ALARM and RECEIVE_BOOT_COMPLETED from AndroidManifest.
- Switch schedule mode back to AndroidScheduleMode.inexact in NotificationService.
Notes
- No dependency changes.
- Reboot rescheduling relies on flutter_local_notifications standard behavior with RECEIVE_BOOT_COMPLETED.

View File

@@ -1,5 +1,6 @@
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -7,7 +8,17 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// //
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound, .badge])
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,13 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController {
@@ -29,6 +32,7 @@ class AddSubscriptionController {
DateTime? nextBillingDate;
bool isLoading = false;
String? selectedCategoryId;
String? selectedPaymentCardId;
// Event State
bool isEventActive = false;
@@ -104,6 +108,33 @@ class AddSubscriptionController {
scrollOffset = scrollController.offset;
});
// 언어별 기본 통화 설정
try {
final lang = Localizations.localeOf(context).languageCode;
switch (lang) {
case 'ko':
currency = 'KRW';
break;
case 'ja':
currency = 'JPY';
break;
case 'zh':
currency = 'CNY';
break;
default:
currency = 'USD';
}
} catch (_) {
// Localizations가 아직 준비되지 않은 경우 기본값 유지
}
// 기본 결제수단 설정
try {
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
selectedPaymentCardId = paymentCardProvider.defaultCard?.id;
} catch (_) {}
// 애니메이션 시작
animationController!.forward();
}
@@ -284,25 +315,55 @@ class AddSubscriptionController {
setState(() => isLoading = true);
try {
final ctx = context;
if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission();
if (!ctx.mounted) return;
if (!granted) {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).smsPermissionRequired,
);
if (ctx.mounted) {
// 영구 거부 여부 확인 후 설정 화면 안내
final status = await permission.Permission.sms.status;
if (!ctx.mounted) return;
if (status.isPermanentlyDenied) {
await showDialog(
context: ctx,
builder: (_) => AlertDialog(
title: Text(AppLocalizations.of(ctx).smsPermissionRequired),
content:
Text(AppLocalizations.of(ctx).permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(AppLocalizations.of(ctx).cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (ctx.mounted) Navigator.of(ctx).pop();
},
child: Text(AppLocalizations.of(ctx).openSettings),
),
],
),
);
} else {
AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx).smsPermissionRequired,
);
}
}
return;
}
}
final subscriptions = await SMSService.scanSubscriptions();
if (!ctx.mounted) return;
if (subscriptions.isEmpty) {
if (context.mounted) {
if (ctx.mounted) {
AppSnackBar.showWarning(
context: context,
message: AppLocalizations.of(context).noSubscriptionSmsFound,
context: ctx,
message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
);
}
return;
@@ -434,14 +495,24 @@ class AddSubscriptionController {
double.tryParse(eventPriceController.text.replaceAll(',', ''));
}
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
final originalDateOnly = DateTime(
nextBillingDate!.year,
nextBillingDate!.month,
nextBillingDate!.day,
);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription(
serviceName: serviceNameController.text.trim(),
monthlyCost: monthlyCost,
billingCycle: billingCycle,
nextBillingDate: nextBillingDate!,
nextBillingDate: adjustedNext,
websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId,
paymentCardId: selectedPaymentCardId,
currency: currency,
isEventActive: isEventActive,
eventStartDate: eventStartDate,
@@ -449,6 +520,16 @@ class AddSubscriptionController {
eventPrice: eventPrice,
);
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
}
if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환
}

View File

@@ -12,6 +12,7 @@ import 'package:intl/intl.dart';
import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier {
@@ -33,6 +34,7 @@ class DetailScreenController extends ChangeNotifier {
late String _billingCycle;
late DateTime _nextBillingDate;
String? _selectedCategoryId;
String? _selectedPaymentCardId;
late String _currency;
bool _isLoading = false;
@@ -45,6 +47,7 @@ class DetailScreenController extends ChangeNotifier {
String get billingCycle => _billingCycle;
DateTime get nextBillingDate => _nextBillingDate;
String? get selectedCategoryId => _selectedCategoryId;
String? get selectedPaymentCardId => _selectedPaymentCardId;
String get currency => _currency;
bool get isLoading => _isLoading;
bool get isEventActive => _isEventActive;
@@ -73,6 +76,13 @@ class DetailScreenController extends ChangeNotifier {
}
}
set selectedPaymentCardId(String? value) {
if (_selectedPaymentCardId != value) {
_selectedPaymentCardId = value;
notifyListeners();
}
}
set currency(String value) {
if (_currency != value) {
_currency = value;
@@ -152,6 +162,7 @@ class DetailScreenController extends ChangeNotifier {
_billingCycle = subscription.billingCycle;
_nextBillingDate = subscription.nextBillingDate;
_selectedCategoryId = subscription.categoryId;
_selectedPaymentCardId = subscription.paymentCardId;
_currency = subscription.currency;
// Event State 초기화
@@ -407,8 +418,14 @@ class DetailScreenController extends ChangeNotifier {
subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl;
subscription.billingCycle = _billingCycle;
subscription.nextBillingDate = _nextBillingDate;
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장
final originalDateOnly = DateTime(
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
subscription.nextBillingDate = adjustedNext;
subscription.categoryId = _selectedCategoryId;
subscription.paymentCardId = _selectedPaymentCardId;
subscription.currency = _currency;
// 이벤트 정보 업데이트
@@ -433,6 +450,14 @@ class DetailScreenController extends ChangeNotifier {
'이벤트활성=${subscription.isEventActive}');
// 구독 업데이트
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
await provider.updateSubscription(subscription);
if (context.mounted) {
@@ -575,15 +600,5 @@ class DetailScreenController extends ChangeNotifier {
return colors[hash % colors.length];
}
/// 그라데이션 가져오기
LinearGradient getGradient(Color baseColor) {
return LinearGradient(
colors: [
baseColor,
baseColor.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
// getGradient 제거됨 (그라데이션 미사용)
}

View File

@@ -1,14 +1,19 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/logger.dart';
import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart';
class SmsScanController extends ChangeNotifier {
// 상태 관리
@@ -20,22 +25,32 @@ class SmsScanController extends ChangeNotifier {
List<Subscription> _scannedSubscriptions = [];
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
PaymentCardSuggestion? _currentSuggestion;
PaymentCardSuggestion? get currentSuggestion => _currentSuggestion;
bool _shouldSuggestCardCreation = false;
bool get shouldSuggestCardCreation => _shouldSuggestCardCreation;
int _currentIndex = 0;
int get currentIndex => _currentIndex;
String? _selectedCategoryId;
String? get selectedCategoryId => _selectedCategoryId;
String? _selectedPaymentCardId;
String? get selectedPaymentCardId => _selectedPaymentCardId;
final TextEditingController websiteUrlController = TextEditingController();
final TextEditingController serviceNameController = TextEditingController();
// 의존성
final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
@override
void dispose() {
serviceNameController.dispose();
websiteUrlController.dispose();
super.dispose();
}
@@ -45,8 +60,26 @@ class SmsScanController extends ChangeNotifier {
notifyListeners();
}
void setSelectedPaymentCardId(String? paymentCardId) {
_selectedPaymentCardId = paymentCardId;
if (paymentCardId != null) {
_shouldSuggestCardCreation = false;
}
notifyListeners();
}
void resetWebsiteUrl() {
websiteUrlController.text = '';
serviceNameController.text = '';
}
void updateCurrentServiceName(String value) {
if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim();
final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed);
_scannedSubscriptions[_currentIndex] = updated;
notifyListeners();
}
Future<void> scanSms(BuildContext context) async {
@@ -57,20 +90,47 @@ class SmsScanController extends ChangeNotifier {
notifyListeners();
try {
// Android에서 SMS 권한 확인 및 요청
final ctx = context;
if (!kIsWeb) {
final smsStatus = await permission.Permission.sms.status;
if (!smsStatus.isGranted) {
if (smsStatus.isPermanentlyDenied) {
// 설정 유도 다이얼로그 표시
if (!ctx.mounted) return;
await _showPermissionSettingsDialog(ctx);
_isLoading = false;
notifyListeners();
return;
}
final req = await permission.Permission.sms.request();
if (!ctx.mounted) return;
if (!req.isGranted) {
// 거부됨: 안내 후 종료
if (!ctx.mounted) return;
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired;
_isLoading = false;
notifyListeners();
return;
}
}
}
// SMS 스캔 실행
Log.i('SMS 스캔 시작');
final scannedSubscriptionModels =
final List<SmsScanResult> scanResults =
await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
Log.d('스캔된 구독: ${scanResults.length}');
if (scannedSubscriptionModels.isNotEmpty) {
if (scanResults.isNotEmpty) {
Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
'첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
}
if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) {
if (scanResults.isEmpty) {
Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false;
@@ -80,7 +140,7 @@ class SmsScanController extends ChangeNotifier {
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
_converter.convertResultsToSubscriptions(scanResults);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
@@ -126,7 +186,9 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
websiteUrlController.text = ''; // URL 입력 필드 초기화
websiteUrlController.text = '';
_currentSuggestion = null;
_prepareCurrentSelection(context);
notifyListeners();
} catch (e) {
Log.e('SMS 스캔 중 오류 발생', e);
@@ -139,20 +201,51 @@ class SmsScanController extends ChangeNotifier {
}
}
Future<void> _showPermissionSettingsDialog(BuildContext context) async {
final loc = AppLocalizations.of(context);
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(loc.smsPermissionRequired),
content: Text(loc.permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (context.mounted) Navigator.of(context).pop();
},
child: Text(loc.openSettings),
),
],
),
);
}
Future<void> addCurrentSubscription(BuildContext context) async {
if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex];
final inputName = serviceNameController.text.trim();
final resolvedServiceName =
inputName.isNotEmpty ? inputName : subscription.serviceName;
try {
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final paymentCardProvider =
Provider.of<PaymentCardProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ??
subscription.category ??
getDefaultCategoryId(categoryProvider);
final finalPaymentCardId =
_selectedPaymentCardId ?? paymentCardProvider.defaultCard?.id;
// websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
@@ -164,7 +257,7 @@ class SmsScanController extends ChangeNotifier {
// addSubscription 호출
await provider.addSubscription(
serviceName: subscription.serviceName,
serviceName: resolvedServiceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
@@ -173,6 +266,7 @@ class SmsScanController extends ChangeNotifier {
repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId,
paymentCardId: finalPaymentCardId,
currency: subscription.currency,
);
@@ -195,8 +289,11 @@ class SmsScanController extends ChangeNotifier {
void moveToNextSubscription(BuildContext context) {
_currentIndex++;
websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
websiteUrlController.text = '';
serviceNameController.text = '';
_selectedCategoryId = null;
_forceServiceNameEditing = false;
_prepareCurrentSelection(context);
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
@@ -217,6 +314,11 @@ class SmsScanController extends ChangeNotifier {
_scannedSubscriptions = [];
_currentIndex = 0;
_errorMessage = null;
_selectedPaymentCardId = null;
_currentSuggestion = null;
_shouldSuggestCardCreation = false;
serviceNameController.clear();
_forceServiceNameEditing = false;
notifyListeners();
}
@@ -235,6 +337,100 @@ class SmsScanController extends ChangeNotifier {
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!;
}
if (_shouldEnableServiceNameEditing(currentSub)) {
if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear();
}
} else {
serviceNameController.text = currentSub.serviceName;
}
}
}
String? _getDefaultPaymentCardId(BuildContext context) {
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
return provider.defaultCard?.id;
} catch (_) {
return null;
}
}
void _prepareCurrentSelection(BuildContext context) {
if (_currentIndex >= _scannedSubscriptions.length) {
_selectedPaymentCardId = null;
_currentSuggestion = null;
_forceServiceNameEditing = false;
serviceNameController.clear();
return;
}
final current = _scannedSubscriptions[_currentIndex];
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
serviceNameController.clear();
} else {
serviceNameController.text = current.serviceName;
}
// URL 기본값
if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) {
websiteUrlController.text = current.websiteUrl!;
} else {
websiteUrlController.clear();
}
_currentSuggestion = current.paymentCardSuggestion;
final matchedCardId = _matchCardWithSuggestion(context, _currentSuggestion);
_shouldSuggestCardCreation =
_currentSuggestion != null && matchedCardId == null;
if (matchedCardId != null) {
_selectedPaymentCardId = matchedCardId;
return;
}
// 모델에 직접 카드 정보가 존재하면 우선 사용
if (current.paymentCardId != null) {
_selectedPaymentCardId = current.paymentCardId;
return;
}
_selectedPaymentCardId = _getDefaultPaymentCardId(context);
}
String? _matchCardWithSuggestion(
BuildContext context, PaymentCardSuggestion? suggestion) {
if (suggestion == null) return null;
try {
final provider = Provider.of<PaymentCardProvider>(context, listen: false);
final cards = provider.cards;
if (cards.isEmpty) return null;
if (suggestion.hasLast4) {
for (final card in cards) {
if (card.last4 == suggestion.last4) {
return card.id;
}
}
}
final normalizedIssuer = suggestion.issuerName.toLowerCase();
for (final card in cards) {
final issuer = card.issuerName.toLowerCase();
if (issuer.contains(normalizedIssuer) ||
normalizedIssuer.contains(issuer)) {
return card.id;
}
}
} catch (_) {
return null;
}
return null;
}
bool _shouldEnableServiceNameEditing(Subscription subscription) {
final name = subscription.serviceName.trim();
return name.isEmpty || name == '알 수 없는 서비스';
}
}

View File

@@ -63,6 +63,56 @@ class AppLocalizations {
String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
String get paymentCardManagement =>
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
String get paymentCardManagementDescription =>
_localizedStrings['paymentCardManagementDescription'] ??
'Manage saved cards for subscriptions';
String get addPaymentCard =>
_localizedStrings['addPaymentCard'] ?? 'Add Payment Card';
String get editPaymentCard =>
_localizedStrings['editPaymentCard'] ?? 'Edit Payment Card';
String get paymentCardIssuer =>
_localizedStrings['paymentCardIssuer'] ?? 'Card Name / Issuer';
String get paymentCardLast4 =>
_localizedStrings['paymentCardLast4'] ?? 'Last 4 Digits';
String get paymentCardColor =>
_localizedStrings['paymentCardColor'] ?? 'Card Color';
String get paymentCardIcon =>
_localizedStrings['paymentCardIcon'] ?? 'Card Icon';
String get setAsDefaultCard =>
_localizedStrings['setAsDefaultCard'] ?? 'Set as default card';
String get paymentCardUnassigned =>
_localizedStrings['paymentCardUnassigned'] ?? 'Unassigned';
String get detectedPaymentCard =>
_localizedStrings['detectedPaymentCard'] ?? 'Card detected';
String detectedPaymentCardDescription(String issuer, String last4) {
final template = _localizedStrings['detectedPaymentCardDescription'] ??
'@ was detected from SMS.';
final label = last4.isNotEmpty ? '$issuer · ****$last4' : issuer;
return template.replaceAll('@', label);
}
String get addDetectedPaymentCard =>
_localizedStrings['addDetectedPaymentCard'] ?? 'Add card';
String get paymentCardUnassignedWarning =>
_localizedStrings['paymentCardUnassignedWarning'] ??
'Without a card selection this subscription will be saved as "Unassigned".';
String get addNewCard => _localizedStrings['addNewCard'] ?? 'Add New Card';
String get managePaymentCards =>
_localizedStrings['managePaymentCards'] ?? 'Manage Cards';
String get choosePaymentCard =>
_localizedStrings['choosePaymentCard'] ?? 'Choose Payment Card';
String get analysisCardFilterLabel =>
_localizedStrings['analysisCardFilterLabel'] ?? 'Filter by payment card';
String get analysisCardFilterAll =>
_localizedStrings['analysisCardFilterAll'] ?? 'All cards';
String get cardDefaultBadge =>
_localizedStrings['cardDefaultBadge'] ?? 'Default';
String get noPaymentCards =>
_localizedStrings['noPaymentCards'] ?? 'No payment cards saved yet.';
String get areYouSure => _localizedStrings['areYouSure'] ?? 'Are you sure?';
// SMS 권한 온보딩/설정
String get smsPermissionTitle =>
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
@@ -116,6 +166,7 @@ class AppLocalizations {
// 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version';
String get openStore => _localizedStrings['openStore'] ?? 'Open Store';
String get appDescription =>
_localizedStrings['appDescription'] ?? 'Subscription Management App';
String get developer => _localizedStrings['developer'] ?? 'Developer';
@@ -367,6 +418,9 @@ class AppLocalizations {
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus =>
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get eventDiscountEndsBeforeBilling =>
_localizedStrings['eventDiscountEndsBeforeBilling'] ??
'Event discount ends before billing date';
String get inProgressUnit =>
_localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount =>
@@ -403,6 +457,13 @@ class AppLocalizations {
String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) {
final template = _localizedStrings['smsDetectedDate'] ?? 'Detected on @';
return template.replaceAll('@', date);
}
String get nextBillingDateLabel =>
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category';

View File

@@ -5,10 +5,12 @@ import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter_localizations/flutter_localizations.dart';
import 'models/subscription_model.dart';
import 'models/category_model.dart';
import 'models/payment_card_model.dart';
import 'providers/subscription_provider.dart';
import 'providers/app_lock_provider.dart';
import 'providers/notification_provider.dart';
import 'providers/navigation_provider.dart';
import 'providers/payment_card_provider.dart';
import 'services/notification_service.dart';
import 'providers/category_provider.dart';
import 'providers/locale_provider.dart';
@@ -69,14 +71,17 @@ Future<void> main() async {
await Hive.initFlutter();
Hive.registerAdapter(SubscriptionModelAdapter());
Hive.registerAdapter(CategoryModelAdapter());
Hive.registerAdapter(PaymentCardModelAdapter());
await Hive.openBox<SubscriptionModel>('subscriptions');
await Hive.openBox<CategoryModel>('categories');
await Hive.openBox<PaymentCardModel>('payment_cards');
final appLockBox = await Hive.openBox<bool>('app_lock');
// 알림 서비스를 가장 먼저 초기화
await NotificationService.init();
final subscriptionProvider = SubscriptionProvider();
final categoryProvider = CategoryProvider();
final paymentCardProvider = PaymentCardProvider();
final localeProvider = LocaleProvider();
final notificationProvider = NotificationProvider();
final themeProvider = ThemeProvider();
@@ -84,6 +89,7 @@ Future<void> main() async {
await subscriptionProvider.init();
await categoryProvider.init();
await paymentCardProvider.init();
await localeProvider.init();
await notificationProvider.init();
await themeProvider.initialize();
@@ -110,6 +116,7 @@ Future<void> main() async {
providers: [
ChangeNotifierProvider(create: (_) => subscriptionProvider),
ChangeNotifierProvider(create: (_) => categoryProvider),
ChangeNotifierProvider(create: (_) => paymentCardProvider),
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
ChangeNotifierProvider(create: (_) => notificationProvider),
ChangeNotifierProvider(create: (_) => localeProvider),
@@ -133,7 +140,9 @@ class SubManagerApp extends StatelessWidget {
return MaterialApp(
key: ValueKey(localeProvider.locale),
title: 'Digital Rent Manager',
// Localizations는 MaterialApp 내부에서 초기화되므로
// onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다.
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
debugShowCheckedModeBanner: false,
theme: themeProvider.getTheme(context),
locale: localeProvider.locale,

View File

@@ -0,0 +1,33 @@
import 'package:hive/hive.dart';
part 'payment_card_model.g.dart';
@HiveType(typeId: 2)
class PaymentCardModel extends HiveObject {
@HiveField(0)
String id;
@HiveField(1)
String issuerName;
@HiveField(2)
String last4;
@HiveField(3)
String colorHex;
@HiveField(4)
String iconName;
@HiveField(5)
bool isDefault;
PaymentCardModel({
required this.id,
required this.issuerName,
required this.last4,
required this.colorHex,
required this.iconName,
this.isDefault = false,
});
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'payment_card_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PaymentCardModelAdapter extends TypeAdapter<PaymentCardModel> {
@override
final int typeId = 2;
@override
PaymentCardModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PaymentCardModel(
id: fields[0] as String,
issuerName: fields[1] as String,
last4: fields[2] as String,
colorHex: fields[3] as String,
iconName: fields[4] as String,
isDefault: fields[5] as bool,
);
}
@override
void write(BinaryWriter writer, PaymentCardModel obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.issuerName)
..writeByte(2)
..write(obj.last4)
..writeByte(3)
..write(obj.colorHex)
..writeByte(4)
..write(obj.iconName)
..writeByte(5)
..write(obj.isDefault);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PaymentCardModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,14 @@
/// SMS 스캔 등에서 추출한 결제수단 정보 제안
class PaymentCardSuggestion {
final String issuerName;
final String? last4;
final String? source; // 예: SMS, OCR 등
const PaymentCardSuggestion({
required this.issuerName,
this.last4,
this.source,
});
bool get hasLast4 => last4 != null && last4!.length == 4;
}

View File

@@ -1,3 +1,5 @@
import 'payment_card_suggestion.dart';
class Subscription {
final String id;
final String serviceName;
@@ -10,6 +12,9 @@ class Subscription {
final DateTime? lastPaymentDate;
final String? websiteUrl;
final String currency;
final String? paymentCardId;
final PaymentCardSuggestion? paymentCardSuggestion;
final String? rawMessage;
Subscription({
required this.id,
@@ -23,8 +28,52 @@ class Subscription {
this.lastPaymentDate,
this.websiteUrl,
this.currency = 'KRW',
this.paymentCardId,
this.paymentCardSuggestion,
this.rawMessage,
});
Subscription copyWith({
String? id,
String? serviceName,
double? monthlyCost,
String? billingCycle,
DateTime? nextBillingDate,
String? category,
String? notes,
int? repeatCount,
DateTime? lastPaymentDate,
String? websiteUrl,
String? currency,
String? paymentCardId,
PaymentCardSuggestion? paymentCardSuggestion,
String? rawMessage,
}) {
return Subscription(
id: id ?? this.id,
serviceName: serviceName ?? this.serviceName,
monthlyCost: monthlyCost ?? this.monthlyCost,
billingCycle: billingCycle ?? this.billingCycle,
nextBillingDate: nextBillingDate ?? this.nextBillingDate,
category: category ?? this.category,
notes: notes ?? this.notes,
repeatCount: repeatCount ?? this.repeatCount,
lastPaymentDate: lastPaymentDate ?? this.lastPaymentDate,
websiteUrl: websiteUrl ?? this.websiteUrl,
currency: currency ?? this.currency,
paymentCardId: paymentCardId ?? this.paymentCardId,
paymentCardSuggestion: paymentCardSuggestion ??
(this.paymentCardSuggestion != null
? PaymentCardSuggestion(
issuerName: this.paymentCardSuggestion!.issuerName,
last4: this.paymentCardSuggestion!.last4,
source: this.paymentCardSuggestion!.source,
)
: null),
rawMessage: rawMessage ?? this.rawMessage,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
@@ -38,6 +87,11 @@ class Subscription {
'lastPaymentDate': lastPaymentDate?.toIso8601String(),
'websiteUrl': websiteUrl,
'currency': currency,
'paymentCardId': paymentCardId,
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
'paymentCardSuggestionSource': paymentCardSuggestion?.source,
'rawMessage': rawMessage,
};
}
@@ -56,6 +110,15 @@ class Subscription {
: null,
websiteUrl: map['websiteUrl'] as String?,
currency: map['currency'] as String? ?? 'KRW',
paymentCardId: map['paymentCardId'] as String?,
paymentCardSuggestion: map['paymentCardSuggestionIssuer'] != null
? PaymentCardSuggestion(
issuerName: map['paymentCardSuggestionIssuer'] as String,
last4: map['paymentCardSuggestionLast4'] as String?,
source: map['paymentCardSuggestionSource'] as String?,
)
: null,
rawMessage: map['rawMessage'] as String?,
);
}

View File

@@ -49,6 +49,9 @@ class SubscriptionModel extends HiveObject {
@HiveField(14)
double? eventPrice; // 이벤트 기간 중 가격
@HiveField(15)
String? paymentCardId; // 연결된 결제수단의 ID
SubscriptionModel({
required this.id,
required this.serviceName,
@@ -65,6 +68,7 @@ class SubscriptionModel extends HiveObject {
this.eventStartDate,
this.eventEndDate,
this.eventPrice,
this.paymentCardId,
});
// 주기적 결제 여부 확인

View File

@@ -32,13 +32,14 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
eventStartDate: fields[12] as DateTime?,
eventEndDate: fields[13] as DateTime?,
eventPrice: fields[14] as double?,
paymentCardId: fields[15] as String?,
);
}
@override
void write(BinaryWriter writer, SubscriptionModel obj) {
writer
..writeByte(15)
..writeByte(16)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -68,7 +69,9 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
..writeByte(13)
..write(obj.eventEndDate)
..writeByte(14)
..write(obj.eventPrice);
..write(obj.eventPrice)
..writeByte(15)
..write(obj.paymentCardId);
}
@override

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart';
class AppNavigationObserver extends NavigatorObserver {
@override
@@ -47,6 +48,12 @@ class AppNavigationObserver extends NavigatorObserver {
final routeName = route.settings.name;
if (routeName == null) return;
// 메인 화면('/')은 하단 탭으로 상태를 관리하므로
// 모달 닫힘 등으로 인해 홈 탭으로 강제 전환하지 않도록 무시한다.
if (routeName == AppRoutes.main || routeName == '/') {
return;
}
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;

View File

@@ -114,6 +114,23 @@ class NotificationProvider extends ChangeNotifier {
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
if (value) {
final hasPermission = await NotificationService.checkPermission();
if (!hasPermission) {
final granted = await NotificationService.requestPermission();
if (!granted) {
debugPrint('알림 권한이 부여되지 않았습니다. 일부 알림이 제한될 수 있습니다.');
}
}
final canExact = await NotificationService.canScheduleExactAlarms();
if (!canExact) {
final exactGranted =
await NotificationService.requestExactAlarmsPermission();
if (!exactGranted) {
debugPrint('정확 알람 권한이 없어 근사 알림으로 예약됩니다.');
}
}
// 알림 설정 변경 시 모든 구독의 알림 재예약
// 지연 실행으로 UI 응답성 향상
Future.microtask(() => _rescheduleNotificationsIfNeeded());

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart';
import '../models/payment_card_model.dart';
class PaymentCardProvider extends ChangeNotifier {
late Box<PaymentCardModel> _cardBox;
final List<PaymentCardModel> _cards = [];
List<PaymentCardModel> get cards => List.unmodifiable(_cards);
PaymentCardModel? get defaultCard {
try {
return _cards.firstWhere((card) => card.isDefault);
} catch (_) {
return _cards.isNotEmpty ? _cards.first : null;
}
}
Future<void> init() async {
_cardBox = await Hive.openBox<PaymentCardModel>('payment_cards');
_cards
..clear()
..addAll(_cardBox.values);
_sortCards();
notifyListeners();
}
Future<PaymentCardModel> addCard({
required String issuerName,
required String last4,
required String colorHex,
required String iconName,
bool isDefault = false,
}) async {
if (isDefault) {
await _unsetDefaultCard();
}
final card = PaymentCardModel(
id: const Uuid().v4(),
issuerName: issuerName,
last4: last4,
colorHex: colorHex,
iconName: iconName,
isDefault: isDefault,
);
await _cardBox.put(card.id, card);
_cards.add(card);
_sortCards();
notifyListeners();
return card;
}
Future<void> updateCard(PaymentCardModel updated) async {
final index = _cards.indexWhere((card) => card.id == updated.id);
if (index == -1) return;
if (updated.isDefault) {
await _unsetDefaultCard(exceptId: updated.id);
}
_cards[index] = updated;
await _cardBox.put(updated.id, updated);
_sortCards();
notifyListeners();
}
Future<void> deleteCard(String id) async {
await _cardBox.delete(id);
_cards.removeWhere((card) => card.id == id);
if (!_cards.any((card) => card.isDefault) && _cards.isNotEmpty) {
_cards.first.isDefault = true;
await _cardBox.put(_cards.first.id, _cards.first);
}
_sortCards();
notifyListeners();
}
Future<void> setDefaultCard(String id) async {
final index = _cards.indexWhere((card) => card.id == id);
if (index == -1) return;
await _unsetDefaultCard(exceptId: id);
_cards[index].isDefault = true;
await _cardBox.put(id, _cards[index]);
_sortCards();
notifyListeners();
}
PaymentCardModel? getCardById(String? id) {
if (id == null) return null;
try {
return _cards.firstWhere((card) => card.id == id);
} catch (_) {
return null;
}
}
void _sortCards() {
_cards.sort((a, b) {
if (a.isDefault != b.isDefault) {
return a.isDefault ? -1 : 1;
}
final issuerCompare =
a.issuerName.toLowerCase().compareTo(b.issuerName.toLowerCase());
if (issuerCompare != 0) return issuerCompare;
return a.last4.compareTo(b.last4);
});
}
Future<void> _unsetDefaultCard({String? exceptId}) async {
for (final card in _cards) {
if (card.isDefault && card.id != exceptId) {
card.isDefault = false;
await _cardBox.put(card.id, card);
}
}
}
}

View File

@@ -103,6 +103,14 @@ class SubscriptionProvider extends ChangeNotifier {
}
}
Future<void> _reschedulePaymentNotifications() async {
try {
await NotificationService.reschedulAllNotifications(_subscriptions);
} catch (e) {
debugPrint('결제 알림 재예약 중 오류 발생: $e');
}
}
Future<void> addSubscription({
required String serviceName,
required double monthlyCost,
@@ -110,6 +118,7 @@ class SubscriptionProvider extends ChangeNotifier {
required DateTime nextBillingDate,
String? websiteUrl,
String? categoryId,
String? paymentCardId,
bool isAutoDetected = false,
int repeatCount = 1,
DateTime? lastPaymentDate,
@@ -128,6 +137,7 @@ class SubscriptionProvider extends ChangeNotifier {
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
categoryId: categoryId,
paymentCardId: paymentCardId,
isAutoDetected: isAutoDetected,
repeatCount: repeatCount,
lastPaymentDate: lastPaymentDate,
@@ -145,6 +155,8 @@ class SubscriptionProvider extends ChangeNotifier {
if (isEventActive && eventEndDate != null) {
await _scheduleEventEndNotification(subscription);
}
await _reschedulePaymentNotifications();
} catch (e) {
debugPrint('구독 추가 중 오류 발생: $e');
rethrow;
@@ -176,6 +188,8 @@ class SubscriptionProvider extends ChangeNotifier {
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
await _reschedulePaymentNotifications();
} catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e');
rethrow;
@@ -186,6 +200,8 @@ class SubscriptionProvider extends ChangeNotifier {
try {
await _subscriptionBox.delete(id);
await refreshSubscriptions();
await _reschedulePaymentNotifications();
} catch (e) {
debugPrint('구독 삭제 중 오류 발생: $e');
rethrow;
@@ -213,6 +229,8 @@ class SubscriptionProvider extends ChangeNotifier {
} finally {
_isLoading = false;
notifyListeners();
await _reschedulePaymentNotifications();
}
}
@@ -226,6 +244,7 @@ class SubscriptionProvider extends ChangeNotifier {
title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId,
);
}
}
@@ -251,17 +270,22 @@ class SubscriptionProvider extends ChangeNotifier {
}
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async {
if (_subscriptions.isEmpty) return 0.0;
Future<double> calculateTotalExpense({
String? locale,
List<SubscriptionModel>? subset,
}) async {
final targetSubscriptions = subset ?? _subscriptions;
if (targetSubscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
'대상 구독: ${targetSubscriptions.length}');
double total = 0.0;
for (final subscription in _subscriptions) {
for (final subscription in targetSubscriptions) {
final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
@@ -275,15 +299,19 @@ class SubscriptionProvider extends ChangeNotifier {
total += converted ?? currentPrice;
}
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
'$targetCurrency (대상 ${targetSubscriptions.length}개)');
return total;
}
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
{String? locale}) async {
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({
String? locale,
List<SubscriptionModel>? subset,
}) async {
final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = [];
final targetSubscriptions = subset ?? _subscriptions;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency =
@@ -304,7 +332,7 @@ class SubscriptionProvider extends ChangeNotifier {
}
// 해당 월에 활성화된 구독 계산
for (final subscription in _subscriptions) {
for (final subscription in targetSubscriptions) {
if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice;

View File

@@ -8,6 +8,7 @@ import 'package:submanager/screens/settings_screen.dart';
import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart';
import 'package:submanager/screens/payment_card_management_screen.dart';
class AppRoutes {
static const String splash = '/splash';
@@ -18,6 +19,7 @@ class AppRoutes {
static const String analysis = '/analysis';
static const String settings = '/settings';
static const String smsPermission = '/sms-permission';
static const String paymentCardManagement = '/payment-card-management';
static Map<String, WidgetBuilder> getRoutes() {
return {
@@ -28,6 +30,7 @@ class AppRoutes {
analysis: (context) => const AnalysisScreen(),
settings: (context) => const SettingsScreen(),
smsPermission: (context) => const SmsPermissionScreen(),
paymentCardManagement: (context) => const PaymentCardManagementScreen(),
};
}
@@ -61,6 +64,8 @@ class AppRoutes {
case smsPermission:
return _buildRoute(const SmsPermissionScreen(), routeSettings);
case paymentCardManagement:
return _buildRoute(const PaymentCardManagementScreen(), routeSettings);
default:
return _errorRoute();

View File

@@ -5,11 +5,11 @@ import '../widgets/add_subscription/add_subscription_header.dart';
import '../widgets/add_subscription/add_subscription_form.dart';
import '../widgets/add_subscription/add_subscription_event_section.dart';
import '../widgets/add_subscription/add_subscription_save_button.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
/// 새로운 구독을 추가하는 화면
class AddSubscriptionScreen extends StatefulWidget {
const AddSubscriptionScreen({Key? key}) : super(key: key);
const AddSubscriptionScreen({super.key});
@override
State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState();
@@ -45,7 +45,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
_controller.scrollController.addListener(_onScroll);
return Scaffold(
backgroundColor: AppColors.backgroundColor,
backgroundColor: Theme.of(context).colorScheme.surface,
extendBodyBehindAppBar: true,
appBar: AddSubscriptionAppBar(
controller: _controller,

View File

@@ -1,7 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/payment_card_model.dart';
import '../models/subscription_model.dart';
import '../providers/payment_card_provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../utils/payment_card_utils.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/analysis/analysis_screen_spacer.dart';
import '../widgets/analysis/subscription_pie_chart_card.dart';
@@ -9,6 +14,8 @@ import '../widgets/analysis/total_expense_summary_card.dart';
import '../widgets/analysis/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart';
enum AnalysisCardFilterType { all, unassigned, card }
class AnalysisScreen extends StatefulWidget {
const AnalysisScreen({super.key});
@@ -25,6 +32,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
List<Map<String, dynamic>> _monthlyData = [];
bool _isLoading = true;
String _lastDataHash = '';
AnalysisCardFilterType _filterType = AnalysisCardFilterType.all;
String? _selectedCardId;
@override
void initState() {
@@ -42,7 +51,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
super.didChangeDependencies();
// Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider);
final filtered = _filterSubscriptions(provider.subscriptions);
final currentHash = _calculateDataHash(provider, filtered: filtered);
debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
@@ -64,13 +74,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
}
/// 구독 데이터의 해시값을 계산하여 변경 감지
String _calculateDataHash(SubscriptionProvider provider) {
final subscriptions = provider.subscriptions;
final buffer = StringBuffer();
buffer.write(subscriptions.length);
buffer.write('_');
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
String _calculateDataHash(
SubscriptionProvider provider, {
List<SubscriptionModel>? filtered,
}) {
final subscriptions =
filtered ?? _filterSubscriptions(provider.subscriptions);
final buffer = StringBuffer()
..write(_filterType.name)
..write('_${_selectedCardId ?? 'all'}')
..write('_${subscriptions.length}');
for (final sub in subscriptions) {
buffer.write(
@@ -80,6 +93,38 @@ class _AnalysisScreenState extends State<AnalysisScreen>
return buffer.toString();
}
List<SubscriptionModel> _filterSubscriptions(
List<SubscriptionModel> subscriptions) {
switch (_filterType) {
case AnalysisCardFilterType.all:
return subscriptions;
case AnalysisCardFilterType.unassigned:
return subscriptions.where((sub) => sub.paymentCardId == null).toList();
case AnalysisCardFilterType.card:
final cardId = _selectedCardId;
if (cardId == null) return subscriptions;
return subscriptions
.where((sub) => sub.paymentCardId == cardId)
.toList();
}
}
Future<void> _onFilterChanged(AnalysisCardFilterType type,
{String? cardId}) async {
if (_filterType == type) {
if (type != AnalysisCardFilterType.card || _selectedCardId == cardId) {
return;
}
}
setState(() {
_filterType = type;
_selectedCardId = type == AnalysisCardFilterType.card ? cardId : null;
});
await _loadData();
}
Future<void> _loadData() async {
debugPrint('[AnalysisScreen] _loadData 호출됨');
setState(() {
@@ -89,17 +134,25 @@ class _AnalysisScreenState extends State<AnalysisScreen>
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode;
final filteredSubscriptions = _filterSubscriptions(provider.subscriptions);
// 총 지출 계산 (로케일별 기본 통화로 환산)
_totalExpense = await provider.calculateTotalExpense(locale: locale);
_totalExpense = await provider.calculateTotalExpense(
locale: locale,
subset: filteredSubscriptions,
);
debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
// 월별 데이터 계산 (로케일별 기본 통화로 환산)
_monthlyData = await provider.getMonthlyExpenseData(locale: locale);
_monthlyData = await provider.getMonthlyExpenseData(
locale: locale,
subset: filteredSubscriptions,
);
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
// 현재 데이터 해시값 저장
_lastDataHash = _calculateDataHash(provider);
_lastDataHash =
_calculateDataHash(provider, filtered: filteredSubscriptions);
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
setState(() {
@@ -130,6 +183,128 @@ class _AnalysisScreenState extends State<AnalysisScreen>
);
}
Widget _buildCardFilterSection(
BuildContext context, PaymentCardProvider cardProvider) {
final loc = AppLocalizations.of(context);
final chips = <Widget>[
_buildGenericFilterChip(
context: context,
label: loc.analysisCardFilterAll,
icon: Icons.credit_card,
selected: _filterType == AnalysisCardFilterType.all,
onTap: () => _onFilterChanged(AnalysisCardFilterType.all),
),
const SizedBox(width: 8),
_buildGenericFilterChip(
context: context,
label: loc.paymentCardUnassigned,
icon: Icons.credit_card_off_rounded,
selected: _filterType == AnalysisCardFilterType.unassigned,
onTap: () => _onFilterChanged(AnalysisCardFilterType.unassigned),
),
];
for (final card in cardProvider.cards) {
chips.add(const SizedBox(width: 8));
chips.add(_buildPaymentCardChip(context, card));
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.analysisCardFilterLabel,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: chips),
),
],
),
),
);
}
Widget _buildGenericFilterChip({
required BuildContext context,
required String label,
required IconData icon,
required bool selected,
required VoidCallback onTap,
}) {
final cs = Theme.of(context).colorScheme;
return Semantics(
selected: selected,
button: true,
label: label,
child: ChoiceChip(
label: Text(label),
avatar: Icon(
icon,
size: 16,
color: selected ? cs.onPrimary : cs.onSurfaceVariant,
),
selected: selected,
onSelected: (_) => onTap(),
selectedColor: cs.primary,
labelStyle: TextStyle(
color: selected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
backgroundColor: cs.surface,
side: BorderSide(
color:
selected ? Colors.transparent : cs.outline.withValues(alpha: 0.5),
),
),
);
}
Widget _buildPaymentCardChip(BuildContext context, PaymentCardModel card) {
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
final cs = Theme.of(context).colorScheme;
final selected = _filterType == AnalysisCardFilterType.card &&
_selectedCardId == card.id;
final labelText = '${card.issuerName} · ****${card.last4}';
return Semantics(
label: labelText,
selected: selected,
button: true,
child: ChoiceChip(
avatar: CircleAvatar(
backgroundColor:
selected ? cs.onPrimary : color.withValues(alpha: 0.15),
child: Icon(
icon,
size: 16,
color: selected ? color : cs.onSurface,
),
),
label: Text(labelText),
selected: selected,
onSelected: (_) =>
_onFilterChanged(AnalysisCardFilterType.card, cardId: card.id),
selectedColor: color,
backgroundColor: cs.surface,
labelStyle: TextStyle(
color: selected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
side: BorderSide(
color: selected ? Colors.transparent : color.withValues(alpha: 0.5),
),
),
);
}
@override
Widget build(BuildContext context) {
// Provider를 직접 사용하여 변경 감지
@@ -142,6 +317,9 @@ class _AnalysisScreenState extends State<AnalysisScreen>
);
}
final cardProvider = Provider.of<PaymentCardProvider>(context);
final filteredSubscriptions = _filterSubscriptions(subscriptions);
return CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
@@ -159,9 +337,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
const AnalysisScreenSpacer(),
_buildCardFilterSection(context, cardProvider),
const AnalysisScreenSpacer(),
// 1. 구독 비율 파이 차트
SubscriptionPieChartCard(
subscriptions: subscriptions,
subscriptions: filteredSubscriptions,
animationController: _animationController,
),
@@ -170,7 +352,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 2. 총 지출 요약 카드
TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions,
subscriptions: filteredSubscriptions,
totalExpense: _totalExpense,
animationController: _animationController,
),
@@ -189,6 +371,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 4. 이벤트 분석
EventAnalysisCard(
animationController: _animationController,
subscriptions: filteredSubscriptions,
),
// FloatingNavigationBar를 위한 충분한 하단 여백

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/payment_card_model.dart';
import '../providers/payment_card_provider.dart';
import '../utils/payment_card_utils.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart';
class PaymentCardManagementScreen extends StatelessWidget {
const PaymentCardManagementScreen({super.key});
Future<void> _openForm(BuildContext context, {PaymentCardModel? card}) async {
await PaymentCardFormSheet.show(context, card: card);
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(loc.paymentCardManagement),
),
body: Consumer<PaymentCardProvider>(
builder: (context, provider, child) {
final cards = provider.cards;
if (cards.isEmpty) {
return Center(
child: Text(
loc.noPaymentCards,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant),
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 16),
itemCount: cards.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final card = cards[index];
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
return ListTile(
leading: CircleAvatar(
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(icon, color: color),
),
title: Row(
children: [
Expanded(child: Text(card.issuerName)),
if (card.isDefault)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
loc.cardDefaultBadge,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
subtitle: Text('****${card.last4}'),
trailing: PopupMenuButton<String>(
onSelected: (value) =>
_handleMenuSelection(context, value, card, provider),
itemBuilder: (_) => [
PopupMenuItem(
value: 'default',
child: Text(loc.setAsDefaultCard),
),
PopupMenuItem(
value: 'edit',
child: Text(loc.editPaymentCard),
),
PopupMenuItem(
value: 'delete',
child: Text(loc.delete),
),
],
),
onTap: () => _openForm(context, card: card),
);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(context),
icon: const Icon(Icons.add),
label: Text(loc.addPaymentCard),
),
);
}
void _handleMenuSelection(
BuildContext context,
String value,
PaymentCardModel card,
PaymentCardProvider provider,
) async {
switch (value) {
case 'default':
await provider.setDefaultCard(card.id);
break;
case 'edit':
await _openForm(context, card: card);
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(AppLocalizations.of(context).delete),
content: Text(AppLocalizations.of(context).areYouSure),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(AppLocalizations.of(context).cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(AppLocalizations.of(context).delete),
),
],
),
);
if (confirmed == true) {
await provider.deleteCard(card.id);
}
break;
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,6 +6,9 @@ import '../widgets/sms_scan/scan_progress_widget.dart';
import '../widgets/sms_scan/subscription_card_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart';
import '../routes/app_routes.dart';
import '../models/payment_card_suggestion.dart';
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@@ -16,18 +19,21 @@ class SmsScanScreen extends StatefulWidget {
class _SmsScanScreenState extends State<SmsScanScreen> {
late SmsScanController _controller;
late final ScrollController _scrollController;
@override
void initState() {
super.initState();
_controller = SmsScanController();
_controller.addListener(_handleControllerUpdate);
_scrollController = ScrollController();
}
@override
void dispose() {
_controller.removeListener(_handleControllerUpdate);
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
@@ -90,19 +96,72 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24),
SubscriptionCardWidget(
subscription: currentSubscription,
serviceNameController: _controller.serviceNameController,
websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId,
onAdd: () => _controller.addCurrentSubscription(context),
onSkip: () => _controller.skipCurrentSubscription(context),
selectedPaymentCardId: _controller.selectedPaymentCardId,
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable
? _controller.updateCurrentServiceName
: null,
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {
_controller.setSelectedPaymentCardId(newCardId);
}
},
onManageCards: () {
Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
},
onAdd: _handleAddSubscription,
onSkip: _handleSkipSubscription,
detectedCardSuggestion: _controller.currentSuggestion,
showDetectedCardShortcut: _controller.shouldSuggestCardCreation,
onAddDetectedCard: (suggestion) =>
_handleDetectedCardCreation(suggestion),
),
],
);
}
Future<void> _handleAddSubscription() async {
await _controller.addCurrentSubscription(context);
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
void _handleSkipSubscription() {
_controller.skipCurrentSubscription(context);
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
Future<void> _handleDetectedCardCreation(
PaymentCardSuggestion suggestion) async {
final newCardId = await PaymentCardFormSheet.show(
context,
initialIssuerName: suggestion.issuerName,
initialLast4: suggestion.last4,
);
if (newCardId != null) {
_controller.setSelectedPaymentCardId(newCardId);
}
}
void _scrollToTop() {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero,
child: Column(
children: [

View File

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

View File

@@ -5,6 +5,9 @@ import 'package:flutter/foundation.dart';
import 'dart:io' show Platform;
import '../models/subscription_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../navigator_key.dart';
import '../l10n/app_localizations.dart';
import '../services/currency_util.dart';
class NotificationService {
static final FlutterLocalNotificationsPlugin _notifications =
@@ -17,6 +20,24 @@ class NotificationService {
static const _reminderHourKey = 'reminder_hour';
static const _reminderMinuteKey = 'reminder_minute';
static const _dailyReminderKey = 'daily_reminder_enabled';
static const int _maxDailyReminderSlots = 7;
static const String _paymentPayloadPrefix = 'payment:';
static const String _paymentChannelId = 'subscription_channel_v2';
static const String _expirationChannelId = 'expiration_channel_v2';
static String get paymentChannelId => _paymentChannelId;
static String get expirationChannelId => _expirationChannelId;
static String _paymentPayload(String subscriptionId) =>
'$_paymentPayloadPrefix$subscriptionId';
static bool _matchesPaymentPayload(String? payload) =>
payload != null && payload.startsWith(_paymentPayloadPrefix);
static String? _subscriptionIdFromPaymentPayload(String? payload) =>
_matchesPaymentPayload(payload)
? payload!.substring(_paymentPayloadPrefix.length)
: null;
// 초기화 상태를 추적하기 위한 플래그
static bool _initialized = false;
@@ -56,6 +77,33 @@ class NotificationService {
InitializationSettings(android: androidSettings, iOS: iosSettings);
await _notifications.initialize(initSettings);
// Android 채널을 선제적으로 생성하여 중요도/진동이 확실히 적용되도록 함
if (Platform.isAndroid) {
final androidImpl =
_notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidImpl != null) {
try {
await androidImpl
.createNotificationChannel(const AndroidNotificationChannel(
_paymentChannelId,
'Subscription Notifications',
description: 'Channel for subscription reminders',
importance: Importance.high,
));
await androidImpl
.createNotificationChannel(const AndroidNotificationChannel(
_expirationChannelId,
'Expiration Notifications',
description: 'Channel for subscription expiration reminders',
importance: Importance.high,
));
} catch (e) {
debugPrint('안드로이드 채널 생성 실패: $e');
}
}
}
_initialized = true;
debugPrint('알림 서비스 초기화 완료');
} catch (e) {
@@ -122,20 +170,32 @@ class NotificationService {
return;
}
// 기존 알림 모두 취소
await cancelAllNotifications();
final pendingRequests =
await _notifications.pendingNotificationRequests();
// 알림 설정 가져오기
final isPaymentEnabled = await isPaymentNotificationEnabled();
if (!isPaymentEnabled) return;
if (!isPaymentEnabled) {
await _cancelOrphanedPaymentReminderNotifications(
const <String>{},
pendingRequests,
);
return;
}
final reminderDays = await getReminderDays();
final reminderHour = await getReminderHour();
final reminderMinute = await getReminderMinute();
final isDailyReminder = await isDailyReminderEnabled();
// 각 구독에 대해 알림 재설정
final activeSubscriptionIds =
subscriptions.map((subscription) => subscription.id).toSet();
for (final subscription in subscriptions) {
await _cancelPaymentReminderNotificationsForSubscription(
subscription,
pendingRequests,
);
await schedulePaymentReminder(
subscription: subscription,
reminderDays: reminderDays,
@@ -144,11 +204,78 @@ class NotificationService {
isDailyReminder: isDailyReminder,
);
}
await _cancelOrphanedPaymentReminderNotifications(
activeSubscriptionIds,
pendingRequests,
);
} catch (e) {
debugPrint('알림 일정 재설정 중 오류 발생: $e');
}
}
static Future<void> _cancelPaymentReminderNotificationsForSubscription(
SubscriptionModel subscription,
List<PendingNotificationRequest> pendingRequests,
) async {
final baseId = subscription.id.hashCode;
final payload = _paymentPayload(subscription.id);
final idsToCancel = <int>{};
for (final request in pendingRequests) {
final matchesPayload = request.payload == payload;
final matchesIdPattern = request.id == baseId ||
(request.id > baseId &&
request.id <= baseId + _maxDailyReminderSlots);
if (matchesPayload || matchesIdPattern) {
idsToCancel.add(request.id);
}
}
for (final id in idsToCancel) {
try {
await _notifications.cancel(id);
} catch (e) {
debugPrint('결제 알림 취소 중 오류 발생: $e');
}
}
if (idsToCancel.isNotEmpty) {
pendingRequests
.removeWhere((request) => idsToCancel.contains(request.id));
}
}
static Future<void> _cancelOrphanedPaymentReminderNotifications(
Set<String> activeSubscriptionIds,
List<PendingNotificationRequest> pendingRequests,
) async {
final idsToCancel = <int>{};
for (final request in pendingRequests) {
final subscriptionId = _subscriptionIdFromPaymentPayload(request.payload);
if (subscriptionId != null &&
!activeSubscriptionIds.contains(subscriptionId)) {
idsToCancel.add(request.id);
}
}
for (final id in idsToCancel) {
try {
await _notifications.cancel(id);
} catch (e) {
debugPrint('고아 결제 알림 취소 중 오류 발생: $e');
}
}
if (idsToCancel.isNotEmpty) {
pendingRequests
.removeWhere((request) => idsToCancel.contains(request.id));
}
}
static Future<bool> requestPermission() async {
// 웹 플랫폼인 경우 false 반환
if (_isWeb) return false;
@@ -218,12 +345,70 @@ class NotificationService {
return true; // 기본값
}
// Android: 정확 알람 권한 가능 여부 확인 (S+)
static Future<bool> canScheduleExactAlarms() async {
if (_isWeb) return false;
if (Platform.isAndroid) {
final android = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
final can = await android.canScheduleExactNotifications();
return can ?? true; // 하위 버전은 true 간주
}
}
return true;
}
// Android: 정확 알람 권한 요청 (Android 12+에서 설정 화면으로 이동)
static Future<bool> requestExactAlarmsPermission() async {
if (_isWeb) return false;
if (Platform.isAndroid) {
final android = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
final granted = await android.requestExactAlarmsPermission();
return granted ?? false;
}
}
return false;
}
static Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
if (_isWeb) {
return AndroidScheduleMode.exactAllowWhileIdle;
}
if (Platform.isAndroid) {
try {
final canExact = await canScheduleExactAlarms();
if (kDebugMode) {
debugPrint(
'[NotificationService] canScheduleExactAlarms result: $canExact');
}
if (!canExact) {
if (kDebugMode) {
debugPrint(
'[NotificationService] exact alarm unavailable → use inexact mode');
}
return AndroidScheduleMode.inexactAllowWhileIdle;
}
} catch (e) {
debugPrint('정확 알람 권한 확인 중 오류 발생: $e');
return AndroidScheduleMode.inexactAllowWhileIdle;
}
}
return AndroidScheduleMode.exactAllowWhileIdle;
}
// 알림 스케줄 설정
static Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
String? payload,
String? channelId,
}) async {
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
if (_isWeb || !_initialized) {
@@ -232,15 +417,34 @@ class NotificationService {
}
try {
const androidDetails = AndroidNotificationDetails(
'subscription_channel',
'구독 알림',
channelDescription: '구독 관련 알림을 보여줍니다.',
final ctx = navigatorKey.currentContext;
String channelName;
if (channelId == _expirationChannelId) {
channelName = ctx != null
? AppLocalizations.of(ctx).expirationReminder
: 'Expiration Notifications';
} else {
channelName = ctx != null
? AppLocalizations.of(ctx).notifications
: 'Subscription Notifications';
}
final effectiveChannelId = channelId ?? _paymentChannelId;
final androidDetails = AndroidNotificationDetails(
effectiveChannelId,
channelName,
channelDescription: channelName,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
);
const iosDetails = DarwinNotificationDetails();
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
// tz.local 초기화 확인 및 재시도
tz.Location location;
@@ -260,15 +464,26 @@ class NotificationService {
}
}
// 과거 시각 방지: 최소 1분 뒤로 조정
final nowTz = tz.TZDateTime.now(location);
var target = tz.TZDateTime.from(scheduledDate, location);
if (!target.isAfter(nowTz)) {
target = nowTz.add(const Duration(minutes: 1));
}
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] scheduleNotification scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule(
id,
title,
body,
tz.TZDateTime.from(scheduledDate, location),
const NotificationDetails(android: androidDetails, iOS: iosDetails),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
target,
NotificationDetails(android: androidDetails, iOS: iosDetails),
androidScheduleMode: scheduleMode,
payload: payload,
);
} catch (e) {
debugPrint('알림 예약 중 오류 발생: $e');
@@ -307,23 +522,25 @@ class NotificationService {
try {
final notificationId = subscription.id.hashCode;
const androidDetails = AndroidNotificationDetails(
'subscription_channel',
'구독 알림',
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
importance: Importance.high,
priority: Priority.high,
);
final ctx = navigatorKey.currentContext;
final title = ctx != null
? AppLocalizations.of(ctx).expirationReminder
: 'Expiration Reminder';
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
final notificationDetails = NotificationDetails(
android: AndroidNotificationDetails(
_paymentChannelId,
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
);
// tz.local 초기화 확인 및 재시도
@@ -344,15 +561,35 @@ class NotificationService {
}
}
final nowTz = tz.TZDateTime.now(location);
var fireAt = tz.TZDateTime.from(subscription.nextBillingDate, location);
if (kDebugMode) {
debugPrint('[NotificationService] scheduleSubscriptionNotification'
' id=${subscription.id.hashCode} tz=${location.name}'
' now=$nowTz target=$fireAt service=${subscription.serviceName}');
}
if (!fireAt.isAfter(nowTz)) {
// 이미 지난 시각이면 예약 생략
if (kDebugMode) {
debugPrint(
'[NotificationService] skip scheduleSubscriptionNotification (past)');
}
return;
}
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] scheduleSubscriptionNotification scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule(
notificationId,
'구독 만료 알림',
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
tz.TZDateTime.from(subscription.nextBillingDate, location),
title,
_buildExpirationBody(subscription),
fireAt,
notificationDetails,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
androidScheduleMode: scheduleMode,
);
} catch (e) {
debugPrint('구독 알림 예약 중 오류 발생: $e');
@@ -373,55 +610,18 @@ class NotificationService {
static Future<void> schedulePaymentNotification(
SubscriptionModel subscription) async {
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
if (_isWeb || !_initialized) {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
final paymentDate = subscription.nextBillingDate;
final reminderDate = paymentDate.subtract(const Duration(days: 3));
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
location = tz.local;
} catch (e) {
// tz.local이 초기화되지 않은 경우 재시도
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
try {
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
location = tz.local;
} catch (_) {
// 그래도 실패하면 UTC 사용
debugPrint('타임존 설정 실패, UTC 사용');
tz.setLocalLocation(tz.UTC);
location = tz.UTC;
}
}
await _notifications.zonedSchedule(
subscription.id.hashCode,
'구독 결제 예정 알림',
'${subscription.serviceName} 결제가 3일 후 예정되어 있습니다.',
tz.TZDateTime.from(reminderDate, location),
const NotificationDetails(
android: AndroidNotificationDetails(
'payment_channel',
'Payment Notifications',
channelDescription: 'Channel for subscription payment reminders',
importance: Importance.high,
priority: Priority.high,
),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} catch (e) {
debugPrint('결제 알림 예약 중 오류 발생: $e');
}
if (_isWeb || !_initialized) return;
final reminderDays = await getReminderDays();
final hour = await getReminderHour();
final minute = await getReminderMinute();
final daily = await isDailyReminderEnabled();
await schedulePaymentReminder(
subscription: subscription,
reminderDays: reminderDays,
reminderHour: hour,
reminderMinute: minute,
isDailyReminder: daily,
);
}
static Future<void> scheduleExpirationNotification(
@@ -461,16 +661,20 @@ class NotificationService {
tz.TZDateTime.from(reminderDate, location),
const NotificationDetails(
android: AndroidNotificationDetails(
'expiration_channel',
_expirationChannelId,
'Expiration Notifications',
channelDescription: 'Channel for subscription expiration reminders',
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
androidScheduleMode: await _resolveAndroidScheduleMode(),
);
} catch (e) {
debugPrint('만료 알림 예약 중 오류 발생: $e');
@@ -491,6 +695,9 @@ class NotificationService {
}
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -510,7 +717,7 @@ class NotificationService {
}
// 기본 알림 예약 (지정된 일수 전)
final scheduledDate = subscription.nextBillingDate
final baseLocal = subscription.nextBillingDate
.subtract(Duration(days: reminderDays))
.copyWith(
hour: reminderHour,
@@ -519,57 +726,65 @@ class NotificationService {
millisecond: 0,
microsecond: 0,
);
// 남은 일수에 따른 메시지 생성
String daysText = '$reminderDays일';
if (reminderDays == 1) {
daysText = '내일';
final nowTz = tz.TZDateTime.now(location);
var scheduledDate = tz.TZDateTime.from(baseLocal, location);
if (kDebugMode) {
debugPrint('[NotificationService] schedulePaymentReminder(base)'
' id=${subscription.id.hashCode} tz=${location.name}'
' now=$nowTz requested=$baseLocal scheduled=$scheduledDate'
' days=$reminderDays time=${reminderHour.toString().padLeft(2, '0')}:${reminderMinute.toString().padLeft(2, '0')}'
' service=${subscription.serviceName}');
}
if (!scheduledDate.isAfter(nowTz)) {
// 지정일이 과거면 최소 1분 뒤로
scheduledDate = nowTz.add(const Duration(minutes: 1));
if (kDebugMode) {
debugPrint(
'[NotificationService] schedulePaymentReminder(base) adjusted to $scheduledDate');
}
}
// 남은 일수에 따른 메시지 생성
final daysText = _daysInText(locale, reminderDays);
// 이벤트 종료로 인한 가격 변동 확인
String notificationBody;
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
// 이벤트가 결제일 전에 종료되는 경우
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
notificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
// 일반 알림
final currentPrice = subscription.currentPrice;
notificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
final body = await _buildPaymentBody(subscription, daysText);
final scheduleMode = await _resolveAndroidScheduleMode();
if (kDebugMode) {
debugPrint(
'[NotificationService] schedulePaymentReminder(base) scheduleMode=$scheduleMode');
}
await _notifications.zonedSchedule(
subscription.id.hashCode,
'구독 결제 예정 알림',
notificationBody,
tz.TZDateTime.from(scheduledDate, location),
const NotificationDetails(
title,
body,
scheduledDate,
NotificationDetails(
android: AndroidNotificationDetails(
'subscription_channel',
'Subscription Notifications',
channelDescription: 'Channel for subscription reminders',
_paymentChannelId,
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
iOS: DarwinNotificationDetails(),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
androidScheduleMode: scheduleMode,
payload: _paymentPayload(subscription.id),
);
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
if (isDailyReminder && reminderDays >= 2) {
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
for (int i = reminderDays - 1; i >= 1; i--) {
final dailyDate =
final dailyLocal =
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
hour: reminderHour,
minute: reminderMinute,
@@ -577,50 +792,50 @@ class NotificationService {
millisecond: 0,
microsecond: 0,
);
final dailyDate = tz.TZDateTime.from(dailyLocal, location);
if (kDebugMode) {
debugPrint('[NotificationService] schedulePaymentReminder(daily)'
' id=${subscription.id.hashCode + i} tz=${location.name}'
' now=$nowTz requested=$dailyLocal scheduled=$dailyDate'
' daysLeft=$i');
}
if (!dailyDate.isAfter(nowTz)) {
// 과거면 건너뜀
if (kDebugMode) {
debugPrint('[NotificationService] skip daily (past)');
}
continue;
}
// 남은 일수에 따른 메시지 생성
String remainingDaysText = '$i일';
if (i == 1) {
remainingDaysText = '내일';
}
final remainingDaysText = _daysInText(locale, i);
// 각 날짜에 대한 이벤트 종료 확인
String dailyNotificationBody;
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!
.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
final eventPrice =
subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
dailyNotificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
final currentPrice = subscription.currentPrice;
dailyNotificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
}
final dailyNotificationBody =
await _buildPaymentBody(subscription, remainingDaysText);
await _notifications.zonedSchedule(
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
'구독 결제 예정 알림',
title,
dailyNotificationBody,
tz.TZDateTime.from(dailyDate, location),
const NotificationDetails(
dailyDate,
NotificationDetails(
android: AndroidNotificationDetails(
'subscription_channel',
'Subscription Notifications',
channelDescription: 'Channel for subscription reminders',
_paymentChannelId,
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
autoCancel: false,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
iOS: DarwinNotificationDetails(),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
androidScheduleMode: scheduleMode,
payload: _paymentPayload(subscription.id),
);
}
}
@@ -629,7 +844,109 @@ class NotificationService {
}
}
// 디버그 테스트용: 즉시 결제 알림을 보여줍니다.
static Future<void> showTestPaymentNotification() async {
if (_isWeb || !_initialized) return;
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
final amountText =
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
final body = '테스트 구독 • $amountText';
await _notifications.show(
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
'subscription_channel',
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
);
} catch (e) {
debugPrint('테스트 결제 알림 표시 실패: $e');
}
}
static String getNotificationBody(String serviceName, double amount) {
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
}
static Future<String> _buildPaymentBody(
SubscriptionModel subscription, String daysText) async {
final ctx = navigatorKey.currentContext;
final locale =
ctx != null ? AppLocalizations.of(ctx).locale.languageCode : 'en';
final warnText = ctx != null
? AppLocalizations.of(ctx).eventDiscountEndsBeforeBilling
: 'Event discount ends before billing date';
final amountText = await CurrencyUtil.formatAmountWithLocale(
subscription.currentPrice, subscription.currency, locale);
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
return '${subscription.serviceName}$amountText$daysText\n⚠️ $warnText';
}
// 일반 알림
if (ctx != null) {
return '${subscription.serviceName}$amountText$daysText';
}
return '${subscription.serviceName}$amountText$daysText';
}
static String _buildExpirationBody(SubscriptionModel subscription) {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
final date =
AppLocalizations.of(ctx).formatDate(subscription.nextBillingDate);
return '${subscription.serviceName}$date';
}
return '${subscription.serviceName}${subscription.nextBillingDate.toLocal()}';
}
static String _getLocaleCode() {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
return AppLocalizations.of(ctx).locale.languageCode;
}
return 'en';
}
static String _paymentReminderTitle(String locale) {
switch (locale) {
case 'ko':
return '결제 예정 알림';
case 'ja':
return '支払い予定の通知';
case 'zh':
return '付款提醒';
default:
return 'Payment Reminder';
}
}
static String _daysInText(String locale, int days) {
switch (locale) {
case 'ko':
return '$days일';
case 'ja':
return '$days日後';
case 'zh':
return '$days天后';
default:
return 'in $days day(s)';
}
}
}

View File

@@ -0,0 +1,14 @@
import '../../models/subscription_model.dart';
import '../../models/payment_card_suggestion.dart';
class SmsScanResult {
final SubscriptionModel model;
final PaymentCardSuggestion? cardSuggestion;
final String? rawMessage;
SmsScanResult({
required this.model,
this.cardSuggestion,
this.rawMessage,
});
}

View File

@@ -1,21 +1,21 @@
import '../../models/subscription.dart';
import '../../models/subscription_model.dart';
import 'sms_scan_result.dart';
class SubscriptionConverter {
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> convertModelsToSubscriptions(
List<SubscriptionModel> models) {
List<Subscription> convertResultsToSubscriptions(
List<SmsScanResult> results) {
final result = <Subscription>[];
for (var model in models) {
for (final smsResult in results) {
try {
final subscription = _convertSingle(model);
final subscription = _convertSingle(smsResult);
result.add(subscription);
// 개발 편의를 위한 디버그 로그
// ignore: avoid_print
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
'모델 변환 성공: ${smsResult.model.serviceName}, 카테고리ID: ${smsResult.model.categoryId}, URL: ${smsResult.model.websiteUrl}, 통화: ${smsResult.model.currency}');
} catch (e) {
// ignore: avoid_print
print('모델 변환 중 오류 발생: $e');
@@ -26,7 +26,8 @@ class SubscriptionConverter {
}
// 단일 모델 변환
Subscription _convertSingle(SubscriptionModel model) {
Subscription _convertSingle(SmsScanResult result) {
final model = result.model;
return Subscription(
id: model.id,
serviceName: model.serviceName,
@@ -38,6 +39,9 @@ class SubscriptionConverter {
lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl,
currency: model.currency,
paymentCardId: model.paymentCardId,
paymentCardSuggestion: result.cardSuggestion,
rawMessage: result.rawMessage,
);
}

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb, compute;
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
import '../models/subscription_model.dart';
@@ -5,11 +7,14 @@ import '../utils/logger.dart';
import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart';
class SmsScanner {
final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async {
Future<List<SmsScanResult>> scanForSubscriptions() async {
try {
List<dynamic> smsList;
Log.d('SmsScanner: 스캔 시작');
@@ -35,38 +40,41 @@ class SmsScanner {
return [];
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SubscriptionModel> subscriptions = [];
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
final filteredSms = smsList
.whereType<Map<String, dynamic>>()
.where(_isEligibleSubscriptionSms)
.toList();
// 서비스명별로 SMS 메시지 그룹화
for (final sms in smsList) {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
if (!serviceGroups.containsKey(serviceName)) {
serviceGroups[serviceName] = [];
}
serviceGroups[serviceName]!.add(sms);
Log.d(
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}');
if (filteredSms.isEmpty) {
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
return [];
}
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SmsScanResult> subscriptions = [];
final serviceGroups = _groupMessagesByIdentifier(filteredSms);
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
// 그룹화된 데이터로 구독 분석
for (final entry in serviceGroups.entries) {
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}');
final repeatResult = _detectRepeatingSubscriptions(entry.value);
if (repeatResult == null) {
Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}');
continue;
}
// 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 2) {
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) {
Log.i(
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
subscriptions.add(subscription);
} else {
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
}
final result =
_parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
if (result != null) {
Log.i(
'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}');
subscriptions.add(result);
} else {
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
}
}
@@ -106,7 +114,7 @@ class SmsScanner {
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
@@ -134,7 +142,11 @@ class SmsScanner {
}
DateTime? nextBillingDate;
if (nextBillingDateStr != null) {
// 외부에서 계산된 다음 결제일이 있으면 우선 사용
final overrideNext = sms['overrideNextBillingDate'] as String?;
if (overrideNext != null) {
nextBillingDate = DateTime.tryParse(overrideNext);
} else if (nextBillingDateStr != null) {
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
}
@@ -146,10 +158,14 @@ class SmsScanner {
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle);
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle,
);
// 주말/공휴일 보정
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel(
final model = SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
serviceName: serviceName,
monthlyCost: monthlyCost,
@@ -161,11 +177,84 @@ class SmsScanner {
websiteUrl: _extractWebsiteUrl(serviceName),
currency: currency, // 통화 단위 설정
);
final suggestion = _extractPaymentCardSuggestion(message);
return SmsScanResult(
model: model,
cardSuggestion: suggestion,
rawMessage: message,
);
} catch (e) {
return null;
}
}
PaymentCardSuggestion? _extractPaymentCardSuggestion(String message) {
if (message.isEmpty) return null;
final issuer = _detectCardIssuer(message);
final last4 = _detectCardLast4(message);
if (issuer == null && last4 == null) {
return null;
}
return PaymentCardSuggestion(
issuerName: issuer ?? '결제수단',
last4: last4,
source: 'sms',
);
}
String? _detectCardIssuer(String message) {
final normalized = message.toLowerCase();
const issuerKeywords = {
'KB국민카드': ['kb국민', '국민카드', 'kb card', 'kookmin'],
'신한카드': ['신한', 'shinhan'],
'우리카드': ['우리카드', 'woori'],
'하나카드': ['하나카드', 'hana card', 'hana'],
'농협카드': ['농협', 'nh', '농협카드'],
'BC카드': ['bc카드', 'bc card'],
'삼성카드': ['삼성카드', 'samsung card'],
'롯데카드': ['롯데카드', 'lotte card'],
'현대카드': ['현대카드', 'hyundai card'],
'씨티카드': ['씨티카드', 'citi card', 'citibank'],
'카카오뱅크': ['카카오뱅크', 'kakaobank'],
'토스뱅크': ['토스뱅크', 'toss bank'],
'Visa': ['visa'],
'Mastercard': ['mastercard', 'master card'],
'American Express': ['amex', 'american express'],
};
for (final entry in issuerKeywords.entries) {
final match = entry.value.any((keyword) => normalized.contains(keyword));
if (match) {
return entry.key;
}
}
return null;
}
String? _detectCardLast4(String message) {
final patterns = [
RegExp(r'\*{3,}\s*(\d{4})'),
RegExp(r'끝번호\s*(\d{4})'),
RegExp(r'마지막\s*(\d{4})'),
RegExp(r'\((\d{4})\)'),
RegExp(r'ending(?: in)?\s*(\d{4})', caseSensitive: false),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(message);
if (match != null && match.groupCount >= 1) {
final candidate = match.group(1);
if (candidate != null && candidate.length == 4) {
return candidate;
}
}
}
return null;
}
// 다음 결제일 계산 (현재 날짜 기준으로 조정)
DateTime _calculateNextBillingDate(
DateTime billingDate, String billingCycle) {
@@ -190,7 +279,9 @@ class SmsScanner {
}
}
return DateTime(year, month, billingDate.day);
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = billingDate.day.clamp(1, dim);
return DateTime(year, month, day);
} else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인
final thisYearBilling =
@@ -277,43 +368,80 @@ class SmsScanner {
}
}
const List<String> _paymentLikeKeywords = [
'승인',
'결제',
'청구',
'charged',
'charge',
'payment',
'billed',
'purchase',
];
const List<String> _blockedKeywords = [
'otp',
'인증',
'보안',
'verification',
'code',
'코드',
'password',
'pw',
'일회성',
'1회용',
'보안문자',
];
bool _containsPaymentKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _paymentLikeKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _containsBlockedKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _blockedKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _isEligibleSubscriptionSms(Map<String, dynamic> sms) {
final amount = (sms['monthlyCost'] as num?)?.toDouble();
if (amount == null || amount <= 0) {
return false;
}
final message = sms['message'] as String? ?? '';
final isPaymentLike =
(sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message);
final isBlocked =
(sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message);
if (!isPaymentLike || isBlocked) {
return false;
}
return true;
}
// ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) {
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
const subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)',
caseSensitive: false),
RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)',
caseSensitive: false),
RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
];
final results = <Map<String, dynamic>>[];
@@ -324,28 +452,26 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
final dateMillis =
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final lowerBody = body.toLowerCase();
final lowerSender = sender.toLowerCase();
final isSubscription = subscriptionKeywords.any((k) =>
lowerBody.contains(k.toLowerCase()) ||
lowerSender.contains(k.toLowerCase()));
if (!isSubscription) continue;
final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
final amount = _isoExtractAmount(body, amountPatterns);
final isPaymentLike = _containsPaymentKeyword(body);
final isBlocked = _containsBlockedKeyword(body);
final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle);
final normalizedBody = _isoNormalizeBody(body);
results.add({
'serviceName': serviceName,
'address': sender,
'monthlyCost': amount,
'billingCycle': billingCycle,
'message': body,
'normalizedBody': normalizedBody,
'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
'isPaymentLike': isPaymentLike,
'isBlocked': isBlocked,
});
}
@@ -408,6 +534,23 @@ String _isoExtractBillingCycle(String body) {
return 'monthly';
}
String _isoNormalizeBody(String body) {
final patterns = <RegExp>[
RegExp(r'\d{4}[./-]\d{1,2}[./-]\d{1,2}'),
RegExp(r'\d{1,2}[./-]\d{1,2}[./-]\d{2,4}'),
RegExp(r'\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}\s*월\s*\d{1,2}\s*일'),
RegExp(r'\d{1,2}:\d{2}'),
];
var normalized = body;
for (final pattern in patterns) {
normalized = normalized.replaceAll(pattern, ' ');
}
return normalized.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase();
}
DateTime _isoCalculateNextBillingFromDate(
DateTime lastDate, String billingCycle) {
switch (billingCycle) {
@@ -421,3 +564,260 @@ DateTime _isoCalculateNextBillingFromDate(
return lastDate.add(const Duration(days: 30));
}
}
Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
List<dynamic> smsList) {
final Map<String, List<Map<String, dynamic>>> groups = {};
for (final smsEntry in smsList) {
if (smsEntry is! Map) continue;
final sms = Map<String, dynamic>.from(smsEntry as Map<String, dynamic>);
final serviceName = (sms['serviceName'] as String?)?.trim();
final address = (sms['address'] as String?)?.trim();
final sender = (sms['sender'] as String?)?.trim();
String key = (serviceName != null &&
serviceName.isNotEmpty &&
serviceName != '알 수 없는 서비스')
? serviceName
: (address?.isNotEmpty == true
? address!
: (sender?.isNotEmpty == true ? sender! : 'unknown'));
groups.putIfAbsent(key, () => []).add(sms);
}
return groups;
}
class _RepeatDetectionResult {
_RepeatDetectionResult({
required this.baseMessage,
required this.repeatCount,
});
final Map<String, dynamic> baseMessage;
final int repeatCount;
}
enum _MatchType { none, monthly, yearly, identical }
class _MatchedPair {
_MatchedPair(this.first, this.second, this.type);
final int first;
final int second;
final _MatchType type;
}
_RepeatDetectionResult? _detectRepeatingSubscriptions(
List<Map<String, dynamic>> messages) {
if (messages.length < 2) return null;
final sorted = messages.map((sms) => Map<String, dynamic>.from(sms)).toList()
..sort((a, b) {
final da = _parsePaymentDate(a['previousPaymentDate']);
final db = _parsePaymentDate(b['previousPaymentDate']);
return (db ?? DateTime.fromMillisecondsSinceEpoch(0))
.compareTo(da ?? DateTime.fromMillisecondsSinceEpoch(0));
});
final matchedIndices = <int>{};
final matchedPairs = <_MatchedPair>[];
for (int i = 0; i < sorted.length - 1; i++) {
for (int j = i + 1; j < sorted.length && j <= i + 5; j++) {
final matchType = _evaluateMatch(sorted[i], sorted[j]);
if (matchType == _MatchType.none) continue;
matchedIndices.add(i);
matchedIndices.add(j);
matchedPairs.add(_MatchedPair(i, j, matchType));
break;
}
}
if (matchedIndices.length < 2) return null;
final hasValidInterval = matchedPairs.any((pair) =>
pair.type == _MatchType.monthly || pair.type == _MatchType.yearly);
if (!hasValidInterval) return null;
final baseIndex = matchedIndices
.reduce((value, element) => value < element ? value : element);
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);
final overrideDate = _deriveNextBillingDate(sorted, matchedPairs);
if (overrideDate != null) {
baseMessage['overrideNextBillingDate'] = overrideDate.toIso8601String();
}
return _RepeatDetectionResult(
baseMessage: baseMessage,
repeatCount: matchedIndices.length,
);
}
_MatchType _evaluateMatch(
Map<String, dynamic> recent, Map<String, dynamic> previous) {
final amountMatch = _matchByAmountAndInterval(recent, previous);
if (amountMatch != _MatchType.none) {
return amountMatch;
}
if (_areBodiesEquivalent(recent, previous)) {
final inferredInterval = _classifyIntervalByDates(recent, previous);
return inferredInterval == _MatchType.none
? _MatchType.identical
: inferredInterval;
}
return _MatchType.none;
}
_MatchType _matchByAmountAndInterval(
Map<String, dynamic> a, Map<String, dynamic> b) {
final amountA = (a['monthlyCost'] as num?)?.toDouble();
final amountB = (b['monthlyCost'] as num?)?.toDouble();
if (amountA == null || amountB == null) return _MatchType.none;
if (!_isAmountSimilar(amountA, amountB)) return _MatchType.none;
return _classifyIntervalByDates(a, b);
}
_MatchType _classifyIntervalByDates(
Map<String, dynamic> a, Map<String, dynamic> b) {
final dateA = _parsePaymentDate(a['previousPaymentDate']);
final dateB = _parsePaymentDate(b['previousPaymentDate']);
if (dateA == null || dateB == null) return _MatchType.none;
final diffDays = (dateA.difference(dateB).inDays).abs();
if (diffDays >= 27 && diffDays <= 34) {
return _MatchType.monthly;
}
if (diffDays >= 350 && diffDays <= 380) {
return _MatchType.yearly;
}
return _MatchType.none;
}
bool _areBodiesEquivalent(Map<String, dynamic> a, Map<String, dynamic> b) {
final normalizedA = _getNormalizedBody(a);
final normalizedB = _getNormalizedBody(b);
if (normalizedA.isEmpty || normalizedB.isEmpty) return false;
return normalizedA == normalizedB;
}
String _getNormalizedBody(Map<String, dynamic> sms) {
final cached = sms['normalizedBody'] as String?;
if (cached != null && cached.isNotEmpty) return cached;
final message = sms['message'] as String? ?? '';
final normalized = _isoNormalizeBody(message);
sms['normalizedBody'] = normalized;
return normalized;
}
DateTime? _deriveNextBillingDate(
List<Map<String, dynamic>> sorted, List<_MatchedPair> pairs) {
if (pairs.isEmpty) return null;
final targetPair = pairs.firstWhere(
(pair) => pair.type == _MatchType.monthly || pair.type == _MatchType.yearly,
orElse: () => pairs.first,
);
final recent = sorted[targetPair.first];
final previous = sorted[targetPair.second];
final recentDate = _parsePaymentDate(recent['previousPaymentDate']);
final prevDate = _parsePaymentDate(previous['previousPaymentDate']);
return _calculateNextBillingFromPair(recentDate, prevDate, targetPair.type);
}
DateTime? _calculateNextBillingFromPair(
DateTime? recentDate, DateTime? prevDate, _MatchType type) {
if (recentDate == null) return null;
if (type == _MatchType.monthly) {
DateTime candidate = _addMonths(recentDate, 1);
while (!candidate.isAfter(DateTime.now())) {
candidate = _addMonths(candidate, 1);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
if (type == _MatchType.yearly) {
DateTime candidate = DateTime(
recentDate.year + 1,
recentDate.month,
_clampDay(
recentDate.day,
BusinessDayUtil.daysInMonth(recentDate.year + 1, recentDate.month),
),
);
while (!candidate.isAfter(DateTime.now())) {
candidate = DateTime(candidate.year + 1, candidate.month, candidate.day);
}
return BusinessDayUtil.nextBusinessDay(candidate);
}
return _inferMonthlyNextBilling(recentDate, prevDate);
}
DateTime? _inferMonthlyNextBilling(DateTime recentDate, DateTime? prevDate) {
int baseDay = recentDate.day;
if (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) {
baseDay = prevDate.day;
}
}
}
final 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 = _clampDay(baseDay, dim);
var nextBilling = DateTime(year, month, day);
return BusinessDayUtil.nextBusinessDay(nextBilling);
}
DateTime _addMonths(DateTime date, int months) {
final totalMonths = (date.month - 1) + months;
final year = date.year + totalMonths ~/ 12;
final month = totalMonths % 12 + 1;
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = _clampDay(date.day, dim);
return DateTime(year, month, day);
}
int _clampDay(int day, int maxDay) {
if (day < 1) return 1;
if (day > maxDay) return maxDay;
return day;
}
DateTime? _parsePaymentDate(dynamic value) {
if (value is DateTime) return value;
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value);
}
return null;
}
bool _isAmountSimilar(double a, double b) {
final diff = (a - b).abs();
final base = math.max(a.abs(), b.abs());
final tolerance = base * 0.01; // 1% 허용
final minTolerance = base < 10 ? 0.1 : 1.0;
return diff <= math.max(tolerance, minTolerance);
}

View File

@@ -1,2 +1,2 @@
/// URL Matcher 패키지의 export 파일
// URL Matcher 패키지의 export 파일
export 'models/service_info.dart';

View File

@@ -166,6 +166,21 @@ class TestSmsData {
'message':
'[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}'
},
{
'serviceName': 'Enterprise Cloud Suite',
'monthlyCost': 990.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 25).year}-${DateTime(now.year, now.month + 1, 25).month.toString().padLeft(2, '0')}-25',
'isRecurring': true,
'repeatCount': 3,
'sender': '445566',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 25).year}-${DateTime(now.year, now.month - 1, 25).month.toString().padLeft(2, '0')}-25',
'message':
'[Enterprise Cloud] Your enterprise tier has been renewed. \$990.00 USD charged to your card. Next billing date: ${DateTime(now.year, now.month + 1, 25).day}'
},
];
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)

View File

@@ -10,114 +10,119 @@ class AdaptiveTheme {
/// 다크 테마
static ThemeData get darkTheme {
const scheme = ColorScheme.dark(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.errorColor,
surface: Color(0xFF1E1E1E),
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
surface: Color(0xFF1E1E1E),
),
colorScheme: scheme,
scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardThemeData(
color: const Color(0xFF1E1E1E),
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.3),
color: scheme.surface,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Colors.white.withValues(alpha: 0.1), width: 0.5),
color: const Color(0xFFFFFFFF).withValues(alpha: 0.08),
width: 1,
),
),
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF1E1E1E),
foregroundColor: Colors.white,
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: IconThemeData(
color: Colors.white.withValues(alpha: 0.9),
size: 24,
),
),
textTheme: TextTheme(
headlineLarge: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4,
),
bodyLarge: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
// title/icon colors inherit from foregroundColor
),
textTheme: ThemeData.dark(useMaterial3: true)
.textTheme
.copyWith(
headlineLarge: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
bodyLarge: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
labelLarge: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
)
.apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2A2A2A),
fillColor: scheme.surface,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
@@ -126,33 +131,31 @@ class AdaptiveTheme {
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
borderSide: BorderSide(color: scheme.outline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide:
const BorderSide(color: AppColors.primaryColor, width: 1.5),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
borderSide: BorderSide(color: scheme.error, width: 1),
),
labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
@@ -161,8 +164,66 @@ class AdaptiveTheme {
elevation: 0,
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<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(
color: Colors.white.withValues(alpha: 0.1),
color: scheme.outline,
thickness: 1,
space: 16,
),
@@ -171,19 +232,15 @@ class AdaptiveTheme {
/// OLED 최적화 다크 테마
static ThemeData get oledTheme {
return darkTheme.copyWith(
final base = darkTheme;
const oledSurface = Color(0xFF0A0A0A);
return base.copyWith(
scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith(
surface: const Color(0xFF0A0A0A),
),
cardTheme: darkTheme.cardTheme.copyWith(
color: const Color(0xFF0A0A0A),
),
appBarTheme: darkTheme.appBarTheme.copyWith(
backgroundColor: Colors.black,
),
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
fillColor: const Color(0xFF0A0A0A),
colorScheme: base.colorScheme.copyWith(surface: oledSurface),
cardTheme: base.cardTheme.copyWith(color: oledSurface),
appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
inputDecorationTheme: base.inputDecorationTheme.copyWith(
fillColor: oledSurface,
),
);
}

View File

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

View File

@@ -2,354 +2,320 @@ import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppTheme {
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
static ThemeData lightTheme = (() {
// Color scheme for light theme
const scheme = ColorScheme.light(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
error: AppColors.errorColor,
surface: AppColors.surfaceColor,
),
);
// 기본 배경색
scaffoldBackgroundColor: AppColors.backgroundColor,
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
// 카드 스타일 - 글래스모피즘 효과
cardTheme: CardThemeData(
color: AppColors.glassCard,
elevation: 0,
shadowColor: AppColors.shadowBlack,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.glassBorder, width: 1),
),
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
// 기본 배경색
scaffoldBackgroundColor: AppColors.backgroundColor,
// 앱바 스타일 - 글래스모피즘 디자인
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: AppColors.textPrimary,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: IconThemeData(
color: AppColors.primaryColor,
size: 24,
),
),
// 타이포그래피 - Metronic Tailwind 스타일
textTheme: const TextTheme(
// 헤드라인 - 페이지 제목
headlineLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
// 타이틀 - 카드, 섹션 제목
titleLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4,
),
// 본문 텍스트
bodyLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
// 라벨 텍스트
labelLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
),
// 입력 필드 스타일 - 글래스모피즘 디자인
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.glassBackground,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.textSecondary, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5),
),
labelStyle: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: const TextStyle(
color: AppColors.textMuted,
fontSize: 14,
fontWeight: FontWeight.w400,
),
errorStyle: const TextStyle(
color: AppColors.dangerColor,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
// 버튼 스타일 - 프라이머리 버튼
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
// 카드 스타일 - Material 3 표면 중심
cardTheme: CardThemeData(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
// 앱바 스타일 - 기본 M3 사용(투명 배경 유지)
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
textStyle: const TextStyle(
centerTitle: false,
),
// 타이포그래피 - Material 3 + onSurface 정렬
textTheme: ThemeData.light(useMaterial3: true)
.textTheme
.copyWith(
headlineLarge: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
bodyLarge: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
labelLarge: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
)
.apply(
// 본문/헤드라인 공통 색상은 onSurface로 적용
bodyColor: scheme.onSurface,
displayColor: scheme.onSurface,
),
// 입력 필드 스타일 - M3 surface/outline 기반
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surface,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.outline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.error, width: 1.5),
),
labelStyle: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w400,
),
errorStyle: TextStyle(
color: scheme.error,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
// 버튼 스타일 - 프라이머리 버튼
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
),
// 텍스트 버튼 스타일
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
// Text style inherits from theme.labelLarge
),
),
// 아웃라인 버튼 스타일
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: scheme.outline, width: 1),
),
),
// FAB 스타일
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2,
extendedPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
extendedTextStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
// 텍스트 버튼 스타일
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryColor,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// 스위치 스타일 (공통 테마)
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
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(
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,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
),
),
// 아웃라인 버튼 스타일
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryColor,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: const BorderSide(color: AppColors.secondaryColor, width: 1),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
),
// FAB 스타일
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
// 디바이더 스타일
dividerTheme: DividerThemeData(
color: scheme.outline,
thickness: 1,
space: 16,
),
elevation: 2,
extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
extendedTextStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
// 페이지 트랜지션
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
},
),
),
// 스위치 스타일
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor;
}
return Colors.white;
}),
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.secondaryColor.withValues(alpha: 0.5);
}
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),
// 스낵바 스타일 (기본 유지)
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.primary,
contentTextStyle: TextStyle(
color: scheme.onPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
behavior: SnackBarBehavior.floating,
),
side: const BorderSide(color: AppColors.secondaryColor, width: 1.5),
),
// 라디오 버튼 스타일
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor;
}
return AppColors.textSecondary;
}),
),
// 슬라이더 스타일
sliderTheme: SliderThemeData(
activeTrackColor: AppColors.primaryColor,
inactiveTrackColor: AppColors.textSecondary,
thumbColor: AppColors.primaryColor,
overlayColor: AppColors.primaryColor.withValues(alpha: 0.3),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
// 탭바 스타일
tabBarTheme: const TabBarThemeData(
labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor,
labelStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
unselectedLabelStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
// 디바이더 스타일
dividerTheme: const DividerThemeData(
color: AppColors.dividerColor,
thickness: 1,
space: 16,
),
// 페이지 트랜지션
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
},
),
// 스낵바 스타일
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.textPrimary,
contentTextStyle: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
behavior: SnackBarBehavior.floating,
),
);
);
})();
}

View File

@@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
extension AppColorRoles on ColorScheme {
// Semantic roles not present in ColorScheme by default
Color get success => const Color(0xFF22C55E); // green 600
Color get warning => const Color(0xFFF59E0B); // amber 600
Color get info => tertiary; // map info to tertiary
}

View File

@@ -0,0 +1,7 @@
class UIConstants {
static const double pageHorizontalPadding = 16;
static const double adVerticalPadding = 12;
static const double adCardHeight = 88;
static const double cardRadius = 16;
static const double cardOutlineAlpha = 0.5; // for outline color alpha
}

View File

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

View File

@@ -0,0 +1,103 @@
import 'business_day_util.dart';
/// 결제 주기 및 결제일 계산 유틸리티
class BillingDateUtil {
/// 결제 주기를 표준 키로 정규화합니다.
/// 반환값 예: 'monthly' | 'quarterly' | 'half-yearly' | 'yearly' | 'weekly'
static String normalizeCycle(String cycle) {
final c = cycle.trim().toLowerCase();
// 영어 우선 매핑
if (c.contains('monthly')) return 'monthly';
if (c.contains('quarter')) return 'quarterly';
if (c.contains('half') || c.contains('half-year')) return 'half-yearly';
if (c.contains('year')) return 'yearly';
if (c.contains('week')) return 'weekly';
// 한국어
if (cycle.contains('매월') || cycle.contains('월간')) return 'monthly';
if (cycle.contains('분기')) return 'quarterly';
if (cycle.contains('반기')) return 'half-yearly';
if (cycle.contains('매년') || cycle.contains('연간')) return 'yearly';
if (cycle.contains('주간')) return 'weekly';
// 일본어
if (cycle.contains('毎月')) return 'monthly';
if (cycle.contains('四半期')) return 'quarterly';
if (cycle.contains('半年')) return 'half-yearly';
if (cycle.contains('年間')) return 'yearly';
if (cycle.contains('週間')) return 'weekly';
// 중국어(간체/번체 공통 표현 대응)
if (cycle.contains('每月')) return 'monthly';
if (cycle.contains('每季度')) return 'quarterly';
if (cycle.contains('每半年')) return 'half-yearly';
if (cycle.contains('每年')) return 'yearly';
if (cycle.contains('每周') || cycle.contains('每週')) return 'weekly';
// 기본값
return 'monthly';
}
/// 선택된 날짜가 오늘(또는 과거)이면, 결제 주기에 맞춰 다음 회차 날짜로 보정합니다.
/// 이미 미래라면 해당 날짜를 그대로 반환합니다.
static DateTime ensureFutureDate(DateTime selected, String cycle) {
final normalized = normalizeCycle(cycle);
final selectedDateOnly =
DateTime(selected.year, selected.month, selected.day);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
if (selectedDateOnly.isAfter(today)) return selectedDateOnly;
DateTime next = selectedDateOnly;
switch (normalized) {
case 'weekly':
while (!next.isAfter(today)) {
next = next.add(const Duration(days: 7));
}
break;
case 'quarterly':
while (!next.isAfter(today)) {
next = _addMonthsClamped(next, 3);
}
break;
case 'half-yearly':
while (!next.isAfter(today)) {
next = _addMonthsClamped(next, 6);
}
break;
case 'yearly':
while (!next.isAfter(today)) {
next = _addYearsClamped(next, 1);
}
break;
case 'monthly':
default:
while (!next.isAfter(today)) {
next = _addMonthsClamped(next, 1);
}
break;
}
return next;
}
/// month 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다.
static DateTime _addMonthsClamped(DateTime base, int months) {
final totalMonths = base.month - 1 + months;
final year = base.year + totalMonths ~/ 12;
final month = totalMonths % 12 + 1;
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = base.day.clamp(1, dim);
return DateTime(year, month, day);
}
/// year 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다.
static DateTime _addYearsClamped(DateTime base, int years) {
final year = base.year + years;
final dim = BusinessDayUtil.daysInMonth(year, base.month);
final day = base.day.clamp(1, dim);
return DateTime(year, base.month, day);
}
}

View File

@@ -0,0 +1,39 @@
/// 영업일 계산 유틸리티
/// - 주말(토/일)과 일부 고정 공휴일을 제외하고 다음 영업일을 계산합니다.
/// - 음력 기반 공휴일(설/추석 등)은 포함하지 않습니다. 필요 시 외부 소스 연동을 고려하세요.
class BusinessDayUtil {
static bool isWeekend(DateTime date) =>
date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
/// 고정일 한국 공휴일(대체공휴일 미포함)
static const List<String> _fixedHolidays = [
'01-01', // 신정
'03-01', // 삼일절
'05-05', // 어린이날
'06-06', // 현충일
'08-15', // 광복절
'10-03', // 개천절
'10-09', // 한글날
'12-25', // 성탄절
];
static bool isFixedKoreanHoliday(DateTime date) {
final key = '${_two(date.month)}-${_two(date.day)}';
return _fixedHolidays.contains(key);
}
static String _two(int n) => n.toString().padLeft(2, '0');
/// 입력 날짜가 주말/고정 공휴일이면 다음 영업일로 전진합니다.
static DateTime nextBusinessDay(DateTime date) {
var d = DateTime(date.year, date.month, date.day);
while (isWeekend(d) || isFixedKoreanHoliday(d)) {
d = d.add(const Duration(days: 1));
}
return d;
}
/// 대상 월의 말일을 반환합니다.
static int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
/// 결제수단 관련 공통 유틸리티
class PaymentCardUtils {
static const List<String> colorPalette = [
'#FF6B6B',
'#F97316',
'#F59E0B',
'#10B981',
'#06B6D4',
'#3B82F6',
'#6366F1',
'#8B5CF6',
'#EC4899',
'#14B8A6',
'#0EA5E9',
'#94A3B8',
];
static const Map<String, IconData> iconMap = {
'credit_card': Icons.credit_card_rounded,
'payments': Icons.payments_rounded,
'wallet': Icons.account_balance_wallet_rounded,
'bank': Icons.account_balance_rounded,
'shopping': Icons.shopping_bag_rounded,
'subscriptions': Icons.subscriptions_rounded,
'bolt': Icons.bolt_rounded,
};
static IconData iconForName(String name) {
return iconMap[name] ?? Icons.credit_card_rounded;
}
static Color colorFromHex(String hex) {
var value = hex.replaceAll('#', '');
if (value.length == 6) {
value = 'ff$value';
}
return Color(int.parse(value, radix: 16));
}
}

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../models/payment_card_model.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart';
import 'subscription_category_helper.dart';
enum SubscriptionGroupingMode { category, paymentCard }
class SubscriptionGroupData {
final String id;
final String title;
final List<SubscriptionModel> subscriptions;
final SubscriptionGroupingMode mode;
final PaymentCardModel? paymentCard;
final bool isUnassignedCard;
final String? categoryKey;
final String? subtitle;
const SubscriptionGroupData({
required this.id,
required this.title,
required this.subscriptions,
required this.mode,
this.paymentCard,
this.isUnassignedCard = false,
this.categoryKey,
this.subtitle,
});
}
class SubscriptionGroupingHelper {
static const _unassignedCardKey = '__unassigned__';
static List<SubscriptionGroupData> buildGroups({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required SubscriptionGroupingMode mode,
required CategoryProvider categoryProvider,
required PaymentCardProvider paymentCardProvider,
}) {
if (mode == SubscriptionGroupingMode.paymentCard) {
return _groupByPaymentCard(
context: context,
subscriptions: subscriptions,
paymentCardProvider: paymentCardProvider,
);
}
return _groupByCategory(
context: context,
subscriptions: subscriptions,
categoryProvider: categoryProvider,
);
}
static List<SubscriptionGroupData> _groupByCategory({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required CategoryProvider categoryProvider,
}) {
final localizedMap = SubscriptionCategoryHelper.categorizeSubscriptions(
subscriptions, categoryProvider, context);
final orderMap = <String, int>{};
for (var i = 0; i < categoryProvider.categories.length; i++) {
orderMap[categoryProvider.categories[i].name] = i;
}
final groups = localizedMap.entries.map((entry) {
final title =
categoryProvider.getLocalizedCategoryName(context, entry.key);
return SubscriptionGroupData(
id: entry.key,
title: title,
subscriptions: entry.value,
mode: SubscriptionGroupingMode.category,
categoryKey: entry.key,
);
}).toList();
groups.sort((a, b) {
final ai = orderMap[a.categoryKey] ?? 999;
final bi = orderMap[b.categoryKey] ?? 999;
if (ai != bi) {
return ai.compareTo(bi);
}
return a.title.compareTo(b.title);
});
return groups;
}
static List<SubscriptionGroupData> _groupByPaymentCard({
required BuildContext context,
required List<SubscriptionModel> subscriptions,
required PaymentCardProvider paymentCardProvider,
}) {
final map = <String, List<SubscriptionModel>>{};
for (final sub in subscriptions) {
final key = sub.paymentCardId ?? _unassignedCardKey;
map.putIfAbsent(key, () => []).add(sub);
}
final loc = AppLocalizations.of(context);
final groups = <SubscriptionGroupData>[];
map.forEach((key, subs) {
if (key == _unassignedCardKey) {
groups.add(
SubscriptionGroupData(
id: key,
title: loc.paymentCardUnassigned,
subtitle: loc.paymentCardUnassigned,
subscriptions: subs,
mode: SubscriptionGroupingMode.paymentCard,
isUnassignedCard: true,
),
);
} else {
final card = paymentCardProvider.getCardById(key);
final title = card?.issuerName ?? loc.paymentCardUnassigned;
final subtitle =
card != null ? '****${card.last4}' : loc.paymentCardUnassigned;
groups.add(
SubscriptionGroupData(
id: key,
title: title,
subtitle: subtitle,
subscriptions: subs,
mode: SubscriptionGroupingMode.paymentCard,
paymentCard: card,
),
);
}
});
groups.sort((a, b) {
if (a.isUnassignedCard != b.isUnassignedCard) {
return a.isUnassignedCard ? 1 : -1;
}
final aDefault = a.paymentCard?.isDefault ?? false;
final bDefault = b.paymentCard?.isDefault ?? false;
if (aDefault != bDefault) {
return aDefault ? -1 : 1;
}
return a.title.toLowerCase().compareTo(b.title.toLowerCase());
});
return groups;
}
}

View File

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

View File

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

View File

@@ -7,11 +7,14 @@ import '../../l10n/app_localizations.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_selector.dart';
import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../glassmorphism_card.dart';
import '../../theme/app_colors.dart';
import '../payment_card/payment_card_selector.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
// Glass 제거: Material 3 Card 사용
// Material colors only
/// 구독 추가 화면의 폼 섹션
class AddSubscriptionForm extends StatelessWidget {
@@ -45,8 +48,15 @@ class AddSubscriptionForm extends StatelessWidget {
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)),
child: GlassmorphismCard(
backgroundColor: AppColors.glassCard,
child: Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -55,26 +65,19 @@ class AddSubscriptionForm extends StatelessWidget {
// 헤더
Row(
children: [
ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: const Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Colors.white,
),
Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).serviceInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -136,9 +139,8 @@ class AddSubscriptionForm extends StatelessWidget {
),
),
const SizedBox(height: 8),
CurrencySelector(
CurrencyDropdownField(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.currency = value;
@@ -158,8 +160,8 @@ class AddSubscriptionForm extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
color: AppColors.textPrimary,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
@@ -168,7 +170,6 @@ class AddSubscriptionForm extends StatelessWidget {
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (value) {
setState(() {
controller.billingCycle = value;
@@ -203,7 +204,7 @@ class AddSubscriptionForm extends StatelessWidget {
keyboardType: TextInputType.url,
prefixIcon: Icon(
Icons.link_rounded,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
@@ -226,7 +227,6 @@ class AddSubscriptionForm extends StatelessWidget {
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (categoryId) {
setState(() {
controller.selectedCategoryId = categoryId;
@@ -237,6 +237,35 @@ class AddSubscriptionForm extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
Text(
AppLocalizations.of(context).paymentCard,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: controller.selectedPaymentCardId,
onChanged: (cardId) {
setState(() {
controller.selectedPaymentCardId = cardId;
});
},
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {
setState(() {
controller.selectedPaymentCardId = newCardId;
});
}
},
onManageCards: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
),
],
),
),

View File

@@ -26,19 +26,7 @@ class AddSubscriptionHeader extends StatelessWidget {
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: controller.gradientColors[0].withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
),
],
color: Theme.of(context).colorScheme.primary,
),
child: Row(
children: [
@@ -48,10 +36,10 @@ class AddSubscriptionHeader extends StatelessWidget {
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
child: Icon(
Icons.add_rounded,
size: 32,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 16),
@@ -61,20 +49,23 @@ class AddSubscriptionHeader extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).newSubscriptionAdd,
style: const TextStyle(
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
color: Theme.of(context).colorScheme.onPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context).enterServiceInfo,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white70,
color: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.7),
),
),
],

View File

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

View File

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

View File

@@ -1,293 +1,308 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget {
final AnimationController animationController;
final List<SubscriptionModel> subscriptions;
const EventAnalysisCard({
super.key,
required this.animationController,
required this.subscriptions,
});
@override
Widget build(BuildContext context) {
final activeEventSubscriptions =
subscriptions.where((sub) => sub.isCurrentlyInEvent).toList();
if (activeEventSubscriptions.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
final totalSavings = activeEventSubscriptions.fold<double>(
0,
(sum, sub) => sum + sub.eventSavings,
);
return SliverToBoxAdapter(
child: Consumer<SubscriptionProvider>(
builder: (context, provider, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: provider.activeEventSubscriptions.isNotEmpty
? FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
),
),
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text:
AppLocalizations.of(context).eventDiscountStatus,
style: const TextStyle(fontSize: 18),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: AppLocalizations.of(context)
.eventDiscountStatus,
style: const TextStyle(
fontSize: 18,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFF6B6B),
Color(0xFFFE7E7E),
],
),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: AppColors.pureWhite,
),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)
.servicesInProgress(provider
.activeEventSubscriptions
.length),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
),
),
],
),
),
],
FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: Theme.of(context).colorScheme.onError,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFFF6B6B)
.withValues(alpha: 0.1),
const Color(0xFFFF8787)
.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.3),
),
),
child: Row(
children: [
const Icon(
Icons.savings,
color: Color(0xFFFF6B6B),
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(),
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
ThemedText(
AppLocalizations.of(context).eventsInProgress,
style: const TextStyle(
fontSize: 14,
const SizedBox(width: 4),
Text(
AppLocalizations.of(context).servicesInProgress(
activeEventSubscriptions.length),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onError,
),
),
const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100)
.round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.darkNavy
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.darkNavy
.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Row(
children: [
FutureBuilder<String>(
future:
CurrencyUtil.formatAmount(
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
fontSize: 12,
decoration:
TextDecoration
.lineThrough,
color: AppColors
.navyGray,
),
);
}
return const SizedBox();
},
),
const SizedBox(width: 8),
const Icon(
Icons.arrow_forward,
size: 12,
color: AppColors.navyGray,
),
const SizedBox(width: 8),
FutureBuilder<String>(
future:
CurrencyUtil.formatAmount(
sub.eventPrice ??
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
fontSize: 12,
fontWeight:
FontWeight.bold,
color:
Color(0xFF10B981),
),
);
}
return const SizedBox();
},
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.2),
borderRadius:
BorderRadius.circular(4),
),
child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
),
),
),
],
),
);
}).toList(),
],
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(
Icons.savings,
color: Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmount(totalSavings),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
],
),
),
)
: const SizedBox.shrink(),
);
},
const SizedBox(height: 16),
ThemedText(
AppLocalizations.of(context).eventsInProgress,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100).round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Row(
children: [
FutureBuilder<String>(
future: CurrencyUtil.formatAmount(
sub.originalPrice, sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: TextStyle(
fontSize: 12,
decoration:
TextDecoration.lineThrough,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
);
}
return const SizedBox();
},
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward,
size: 12,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
const SizedBox(width: 8),
FutureBuilder<String>(
future: CurrencyUtil.formatAmount(
sub.eventPrice ?? sub.originalPrice,
sub.currency,
),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.success,
),
);
}
return const SizedBox();
},
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatDiscountPercent(context, discountRate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
);
}),
],
),
),
),
),
),
),
);
}
}
String _formatDiscountPercent(BuildContext context, int percent) {
final raw = AppLocalizations.of(context).discountPercent;
// 우선 @ 플레이스홀더가 있으면 치환
if (raw.contains('@')) {
return raw.replaceAll('@', percent.toString());
}
// % 마커가 있으면 첫 번째 %를 숫자%로 치환
if (raw.contains('%')) {
return raw.replaceFirst('%', '$percent%');
}
// 폴백: "99% text" 형태
return '$percent% $raw';
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,15 +10,29 @@ class AnimatedWaveBackground extends StatelessWidget {
final AnimationController pulseController;
const AnimatedWaveBackground({
Key? key,
super.key,
required this.controller,
required this.pulseController,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
final reduce = ReduceMotion.isEnabled(context);
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
final amp = reduce ? 0.3 : 1.0; // 기본 효과 강도 스케일
// 원 크기에 따라 속도/진폭 스케일을 동적으로 계산
// size가 클수록 느리고(차분), 작을수록 빠르고(활발) 크게 움직이게 함
MotionParams paramsFor(double size) {
const ref = 160.0; // 기준 크기
// 진폭 스케일: 0.6 ~ 1.4 사이 (연속)
final ampScale = _clamp(ref / size, 0.6, 1.4) * (reduce ? 0.6 : 1.0);
// 속도 배수: 1~3의 정수로 제한하여 래핑 시 연속성 보장
final raw = 0.8 + (ref / size) * 0.6; // 약 0.8~1.4 범위
int speedMult = raw < 1.2 ? 1 : (raw < 1.6 ? 2 : 3);
if (reduce && speedMult > 2) speedMult = 2; // 감속 모드 상한
return MotionParams(ampScale: ampScale, speedMult: speedMult);
}
return Stack(
children: [
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
@@ -26,22 +40,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller,
builder: (context, child) {
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
final angle = controller.value * 2 * math.pi;
// 사인 함수를 사용하여 부드러운 움직임 생성
final xOffset = 20 * amp * math.sin(angle);
final yOffset = 10 * amp * math.cos(angle);
final p = paramsFor(200);
final angle = controller.value * 2 * math.pi * p.speedMult;
// 사인 함수를 사용하여 부드러운 움직임 생성 (큰 원: 차분)
final xOffset = 20 * amp * p.ampScale * math.sin(angle);
final yOffset = 10 * amp * p.ampScale * math.cos(angle);
return Positioned(
right: -40 + xOffset,
top: -60 + yOffset,
child: Transform.rotate(
// 회전도 선형적으로 변화하도록 수정
angle: 0.2 * amp * math.sin(angle * 0.5),
angle: 0.2 * amp * p.ampScale * math.sin(angle * 0.5),
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(100),
),
),
@@ -53,21 +71,26 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller,
builder: (context, child) {
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
final xOffset = 20 * amp * math.cos(angle);
final yOffset = 10 * amp * math.sin(angle);
final p = paramsFor(220);
final angle =
(controller.value * 2 * math.pi * p.speedMult) + (math.pi / 3);
final xOffset = 20 * amp * p.ampScale * math.cos(angle);
final yOffset = 10 * amp * p.ampScale * math.sin(angle);
return Positioned(
left: -80 + xOffset,
bottom: -70 + yOffset,
child: Transform.rotate(
// 반대 방향으로 회전하도록 설정
angle: -0.3 * amp * math.sin(angle * 0.5),
angle: -0.3 * amp * p.ampScale * math.sin(angle * 0.5),
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(110),
),
),
@@ -80,20 +103,25 @@ class AnimatedWaveBackground extends StatelessWidget {
animation: controller,
builder: (context, child) {
// 세 번째 원은 다른 위상으로 움직이도록 설정
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
final xOffset = 15 * amp * math.sin(angle * 0.7);
final yOffset = 8 * amp * math.cos(angle * 0.7);
final p = paramsFor(120);
final angle = (controller.value * 2 * math.pi * p.speedMult) +
(math.pi * 2 / 3);
final xOffset = 15 * amp * p.ampScale * math.sin(angle * 0.9);
final yOffset = 8 * amp * p.ampScale * math.cos(angle * 0.9);
return Positioned(
right: 40 + xOffset,
bottom: -40 + yOffset,
child: Transform.rotate(
angle: 0.4 * amp * math.cos(angle * 0.5),
angle: 0.4 * amp * p.ampScale * math.cos(angle * 0.5),
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(60),
),
),
@@ -112,7 +140,7 @@ class AnimatedWaveBackground extends StatelessWidget {
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.white.withValues(
color: Theme.of(context).colorScheme.onSurface.withValues(
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
borderRadius: BorderRadius.circular(15),
),
@@ -124,3 +152,13 @@ class AnimatedWaveBackground extends StatelessWidget {
);
}
}
// 내부 유틸리티: 값 범위 제한
double _clamp(double v, double min, double max) =>
v < min ? min : (v > max ? max : v);
class MotionParams {
final double ampScale;
final int speedMult;
MotionParams({required this.ampScale, required this.speedMult});
}

View File

@@ -77,6 +77,12 @@ class AppNavigator {
await Navigator.of(context).pushNamed(AppRoutes.settings);
}
/// 결제수단 관리 화면으로 네비게이션
static Future<void> toPaymentCardManagement(BuildContext context) async {
HapticFeedback.lightImpact();
await Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
}
/// 카테고리 관리 화면으로 네비게이션
static Future<void> toCategoryManagement(BuildContext context) async {
HapticFeedback.lightImpact();

View File

@@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
/// 카테고리별 구독 그룹의 헤더 위젯
///
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
/// 통화별로 구분하여 표시하며, 혼재된 경우 각각 표시합니다.
class CategoryHeaderWidget extends StatelessWidget {
final String categoryName;
final int subscriptionCount;
final double totalCostUSD;
final double totalCostKRW;
final double totalCostJPY;
final double totalCostCNY;
const CategoryHeaderWidget({
Key? key,
required this.categoryName,
required this.subscriptionCount,
required this.totalCostUSD,
required this.totalCostKRW,
required this.totalCostJPY,
required this.totalCostCNY,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
categoryName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Color(0xFF374151),
),
),
Text(
_buildCostDisplay(context),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF6B7280),
),
),
],
),
const SizedBox(height: 8),
const Divider(
height: 1,
thickness: 1,
color: Color(0xFFEEEEEE),
),
],
),
);
}
/// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
// 개수는 항상 표시
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostUSD));
}
// 원화가 있는 경우
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostKRW));
}
// 엔화가 있는 경우
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostJPY));
}
// 위안화가 있는 경우
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostCNY));
}
// 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
final currencyDisplay = currencyParts.join(' + ');
parts.add(currencyDisplay);
}
return parts.join(' · ');
}
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 주요 액션에 사용되는 Primary 버튼
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
@@ -44,26 +43,30 @@ class _PrimaryButtonState extends State<PrimaryButton> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor =
widget.backgroundColor ?? theme.primaryColor;
widget.backgroundColor ?? theme.colorScheme.primary;
final effectiveForegroundColor =
widget.foregroundColor ?? AppColors.pureWhite;
widget.foregroundColor ?? theme.colorScheme.onPrimary;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.width ?? double.infinity,
height: widget.height,
transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02))
? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
: Matrix4.identity(),
child: ElevatedButton(
onPressed: widget.isLoading ? null : widget.onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor,
// 고정 높이와 텍스트 잘림 방지를 위해 최소 사이즈 지정
minimumSize: Size.fromHeight(widget.height),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.borderRadius),
),
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
// 컨테이너에서 높이를 관리하므로 수직 패딩은 0으로 두고
// 수평 여백만 부여하여 작은 높이(예: 48)에서 글자 잘림 방지
padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 16),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor:

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../theme/app_colors.dart';
// import '../../../theme/app_colors.dart';
/// 공통 텍스트 필드 위젯
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
@@ -66,10 +66,10 @@ class BaseTextField extends StatelessWidget {
if (label != null) ...[
Text(
label!,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
@@ -89,22 +89,22 @@ class BaseTextField extends StatelessWidget {
maxLines: maxLines,
minLines: minLines,
readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor,
cursorColor: cursorColor ?? theme.colorScheme.primary,
style: style ??
const TextStyle(
TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
color: Theme.of(context).colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
color: AppColors.textMuted,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
prefixIcon: prefixIcon,
prefixText: prefixText,
suffixIcon: suffixIcon,
filled: true,
fillColor: fillColor ?? AppColors.surfaceColorAlt,
fillColor: fillColor ?? Theme.of(context).colorScheme.surface,
contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -113,15 +113,15 @@ class BaseTextField extends StatelessWidget {
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.primaryColor,
color: theme.colorScheme.primary,
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1,
),
),
disabledBorder: OutlineInputBorder(

View File

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

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../theme/app_colors.dart';
// import '../../../theme/app_colors.dart';
import '../../../providers/category_provider.dart';
/// 카테고리 선택 위젯
@@ -10,8 +10,8 @@ class CategorySelector extends StatelessWidget {
final String? selectedCategoryId;
final ValueChanged<String?> onChanged;
final Color? baseColor;
final List<Color>? gradientColors;
final bool isGlassmorphism;
final List<Color>? gradientColors; // deprecated: ignored
final bool isGlassmorphism; // deprecated: ignored
const CategorySelector({
super.key,
@@ -39,9 +39,9 @@ class CategorySelector extends StatelessWidget {
vertical: 10,
),
decoration: BoxDecoration(
color: _getBackgroundColor(isSelected),
color: _getBackgroundColor(context, isSelected),
borderRadius: BorderRadius.circular(12),
border: _getBorder(isSelected),
border: _getBorder(context, isSelected),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -49,7 +49,7 @@ class CategorySelector extends StatelessWidget {
Icon(
_getCategoryIcon(category),
size: 18,
color: _getTextColor(isSelected),
color: _getTextColor(context, isSelected),
),
const SizedBox(width: 6),
Consumer<CategoryProvider>(
@@ -60,7 +60,7 @@ class CategorySelector extends StatelessWidget {
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _getTextColor(isSelected),
color: _getTextColor(context, isSelected),
),
);
},
@@ -100,38 +100,22 @@ class CategorySelector extends StatelessWidget {
}
}
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
Border? _getBorder(bool isSelected) {
if (isSelected || !isGlassmorphism) {
return null;
}
return Border.all(
color: AppColors.borderColor.withValues(alpha: 0.5),
width: 1.5,
);
}
Color _getTextColor(bool isSelected) {
Color _getBackgroundColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) {
return Colors.white;
return baseColor ?? scheme.primary;
}
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
return scheme.surface;
}
Border? _getBorder(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
if (isSelected) return null;
return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1);
}
Color _getTextColor(BuildContext context, bool isSelected) {
final scheme = Theme.of(context).colorScheme;
return isSelected ? scheme.onPrimary : scheme.onSurface;
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
class CurrencyDropdownField extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const CurrencyDropdownField({
super.key,
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DropdownButtonFormField<String>(
initialValue: currency,
isExpanded: true,
icon: const Icon(Icons.keyboard_arrow_down_rounded),
// 선택된 아이템은 코드만 간결하게 표시하여 오버플로우 방지
selectedItemBuilder: (context) {
final color = theme.colorScheme.onSurface;
return const [
'KRW',
'USD',
'JPY',
'CNY',
].map((code) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
code,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14, color: color),
),
);
}).toList();
},
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.surface,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.6),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
),
items: const [
DropdownMenuItem(
value: 'KRW', child: _CurrencyItem(symbol: '', code: 'KRW')),
DropdownMenuItem(
value: 'USD', child: _CurrencyItem(symbol: '\$', code: 'USD')),
DropdownMenuItem(
value: 'JPY', child: _CurrencyItem(symbol: '¥', code: 'JPY')),
DropdownMenuItem(
value: 'CNY', child: _CurrencyItem(symbol: '¥', code: 'CNY')),
],
onChanged: (val) {
if (val != null) onChanged(val);
},
);
}
}
class _CurrencyItem extends StatelessWidget {
final String symbol;
final String code;
const _CurrencyItem({required this.symbol, required this.code});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.onSurface;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
symbol,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(width: 8),
Text(
code,
style: TextStyle(
fontSize: 14,
color: color,
),
),
],
);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import '../../../theme/ui_constants.dart';
/// 페이지 공통 좌우 패딩과 최대 폭을 보장하는 래퍼
class PageContainer extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final double maxWidth;
const PageContainer({
super.key,
required this.child,
this.padding,
this.maxWidth = 720,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Padding(
padding: padding ??
const EdgeInsets.symmetric(
horizontal: UIConstants.pageHorizontalPadding,
),
child: child,
),
),
);
}
}

View File

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

View File

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

View File

@@ -2,13 +2,16 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/category_provider.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../common/form_fields/currency_selector.dart';
// import '../common/form_fields/currency_selector.dart';
import '../common/form_fields/currency_dropdown_field.dart';
import '../common/form_fields/billing_cycle_selector.dart';
import '../common/form_fields/category_selector.dart';
import '../payment_card/payment_card_selector.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 폼 섹션
@@ -43,19 +46,15 @@ class DetailFormSection extends StatelessWidget {
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -100,28 +99,18 @@ class DetailFormSection extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).currency,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color:
Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
CurrencySelector(
CurrencyDropdownField(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
controller.currency = value;
// 통화 변경시 금액 포맷 업데이트
if (value == 'KRW') {
final amount = double.tryParse(controller
.monthlyCostController.text
.replaceAll(',', ''));
if (amount != null) {
controller.monthlyCostController.text =
amount.toInt().toString();
}
}
},
),
],
@@ -137,17 +126,16 @@ class DetailFormSection extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (value) {
controller.billingCycle = value;
},
@@ -163,7 +151,9 @@ class DetailFormSection extends StatelessWidget {
controller.nextBillingDate = date;
},
label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(),
// 과거 결제일을 가진 항목도 수정 가능하도록 범위 완화
firstDate: DateTime.now()
.subtract(const Duration(days: 365 * 10)),
lastDate:
DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor,
@@ -178,10 +168,10 @@ class DetailFormSection extends StatelessWidget {
children: [
Text(
AppLocalizations.of(context).category,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
@@ -189,7 +179,6 @@ class DetailFormSection extends StatelessWidget {
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
},
@@ -198,6 +187,33 @@ class DetailFormSection extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
Text(
AppLocalizations.of(context).paymentCard,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
PaymentCardSelector(
selectedCardId: controller.selectedPaymentCardId,
onChanged: (cardId) {
controller.selectedPaymentCardId = cardId;
},
onAddCard: () async {
final newCardId =
await PaymentCardFormSheet.show(context);
if (newCardId != null) {
controller.selectedPaymentCardId = newCardId;
}
},
onManageCards: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
),
],
),
),

View File

@@ -3,7 +3,12 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart';
import '../../providers/payment_card_provider.dart';
import '../../services/currency_util.dart';
import '../../utils/payment_card_utils.dart';
import '../../models/payment_card_model.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../routes/app_routes.dart';
import '../website_icon.dart';
import '../../l10n/app_localizations.dart';
@@ -30,11 +35,14 @@ class DetailHeaderSection extends StatelessWidget {
return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
final gradient = controller.getGradient(baseColor);
final paymentCardProvider = context.watch<PaymentCardProvider>();
final paymentCard = paymentCardProvider.getCardById(
controller.selectedPaymentCardId ?? subscription.paymentCardId,
);
return Container(
height: 320,
decoration: BoxDecoration(gradient: gradient),
constraints: const BoxConstraints(minHeight: 320),
decoration: BoxDecoration(color: baseColor),
child: Stack(
children: [
// 배경 패턴
@@ -70,6 +78,7 @@ class DetailHeaderSection extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
@@ -92,7 +101,7 @@ class DetailHeaderSection extends StatelessWidget {
),
],
),
const Spacer(),
const SizedBox(height: 16),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
@@ -173,6 +182,11 @@ class DetailHeaderSection extends StatelessWidget {
.withValues(alpha: 0.8),
),
),
const SizedBox(height: 12),
_buildPaymentCardChip(
context,
paymentCard,
),
],
),
),
@@ -187,42 +201,47 @@ class DetailHeaderSection extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
label: AppLocalizations.of(context)
.nextBillingDate,
value: AppLocalizations.of(context)
.formatDate(
controller.nextBillingDate),
Expanded(
child: _InfoColumn(
label: AppLocalizations.of(context)
.nextBillingDate,
value: AppLocalizations.of(context)
.formatDate(
controller.nextBillingDate),
),
),
FutureBuilder<String>(
future: () async {
final locale = context
.read<LocaleProvider>()
.locale
.languageCode;
final amount = double.tryParse(
controller
.monthlyCostController.text
.replaceAll(',', '')) ??
0;
return CurrencyUtil
.formatAmountWithLocale(
amount,
controller.currency,
locale,
);
}(),
builder: (context, snapshot) {
return _InfoColumn(
label: AppLocalizations.of(context)
.monthlyExpense,
value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end,
);
},
const SizedBox(width: 12),
Expanded(
child: FutureBuilder<String>(
future: () async {
final locale = context
.read<LocaleProvider>()
.locale
.languageCode;
final amount = double.tryParse(
controller
.monthlyCostController
.text
.replaceAll(',', '')) ??
0;
return CurrencyUtil
.formatAmountWithLocale(
amount,
controller.currency,
locale,
);
}(),
builder: (context, snapshot) {
return _InfoColumn(
label: AppLocalizations.of(context)
.monthlyExpense,
value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end,
wrapValue: true,
);
},
),
),
],
),
@@ -269,6 +288,104 @@ class DetailHeaderSection extends StatelessWidget {
return cycle;
}
}
Widget _buildPaymentCardChip(
BuildContext context,
PaymentCardModel? card,
) {
final loc = AppLocalizations.of(context);
if (card == null) {
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(AppRoutes.paymentCardManagement);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.credit_card_off_rounded,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
Text(
loc.paymentCardUnassigned,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios_rounded,
color: Colors.white.withValues(alpha: 0.7),
size: 14,
),
],
),
),
);
}
final color = PaymentCardUtils.colorFromHex(card.colorHex);
final icon = PaymentCardUtils.iconForName(card.iconName);
return GestureDetector(
onTap: () async {
await PaymentCardFormSheet.show(context, card: card);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: color.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 14,
backgroundColor: Colors.white,
child: Icon(
icon,
size: 16,
color: color,
),
),
const SizedBox(width: 10),
Text(
'${card.issuerName} · ****${card.last4}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(width: 8),
Icon(
Icons.edit_rounded,
size: 16,
color: Colors.white.withValues(alpha: 0.8),
),
],
),
),
);
}
}
/// 정보 표시 컬럼
@@ -276,11 +393,13 @@ class _InfoColumn extends StatelessWidget {
final String label;
final String value;
final CrossAxisAlignment alignment;
final bool wrapValue;
const _InfoColumn({
required this.label,
required this.value,
this.alignment = CrossAxisAlignment.start,
this.wrapValue = false,
});
@override
@@ -297,14 +416,27 @@ class _InfoColumn extends StatelessWidget {
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
if (wrapValue)
Text(
value,
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
)
else
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
);
}

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../models/payment_card_model.dart';
import '../../providers/payment_card_provider.dart';
import '../../routes/app_routes.dart';
import '../../utils/payment_card_utils.dart';
import '../payment_card/payment_card_form_sheet.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 결제 정보 섹션
/// 현재 구독에 연결된 결제수단 정보를 요약하여 보여준다.
class DetailPaymentInfoSection extends StatelessWidget {
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailPaymentInfoSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
return Consumer2<DetailScreenController, PaymentCardProvider>(
builder: (context, detailController, paymentCardProvider, child) {
final baseColor = detailController.getCardColor();
final paymentCard = paymentCardProvider.getCardById(
detailController.selectedPaymentCardId ??
detailController.subscription.paymentCardId,
);
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.credit_card_rounded,
color: baseColor,
size: 22,
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).paymentCard,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const Spacer(),
TextButton.icon(
onPressed: () {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
},
icon: const Icon(Icons.settings_rounded, size: 18),
label: Text(
AppLocalizations.of(context).paymentCardManagement,
),
),
],
),
const SizedBox(height: 16),
_PaymentCardInfoTile(
card: paymentCard,
onTap: () async {
if (paymentCard != null) {
await PaymentCardFormSheet.show(
context,
card: paymentCard,
);
} else {
Navigator.of(context)
.pushNamed(AppRoutes.paymentCardManagement);
}
},
),
],
),
),
),
),
);
},
);
}
}
class _PaymentCardInfoTile extends StatelessWidget {
final PaymentCardModel? card;
final VoidCallback onTap;
const _PaymentCardInfoTile({
required this.card,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context);
final hasCard = card != null;
final chipColor = hasCard
? PaymentCardUtils.colorFromHex(card!.colorHex)
: scheme.onSurfaceVariant;
final icon = hasCard
? PaymentCardUtils.iconForName(card!.iconName)
: Icons.credit_card_off_rounded;
return Material(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: chipColor.withValues(alpha: 0.15),
child: Icon(
icon,
color: chipColor,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.paymentCard,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
hasCard
? '${card!.issuerName} · ****${card!.last4}'
: loc.paymentCardUnassigned,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
),
),
Icon(
hasCard ? Icons.edit_rounded : Icons.add_rounded,
color: scheme.onSurfaceVariant,
),
],
),
),
),
);
}
}

View File

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

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:ui';
import '../../utils/reduce_motion.dart';
import '../../theme/app_colors.dart';
// Material 3 기반 다이얼로그
import '../common/buttons/primary_button.dart';
import '../common/buttons/secondary_button.dart';
@@ -18,148 +16,133 @@ class DeleteConfirmationDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
child: Stack(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 글래스모피즘 배경
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4),
sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4),
// 아이콘
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.error.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.delete_forever_rounded,
color: Theme.of(context).colorScheme.error,
size: 40,
),
),
const SizedBox(height: 24),
// 타이틀
Text(
'구독 삭제',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 12),
// 설명
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.5,
),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
children: [
const TextSpan(text: '정말로 '),
TextSpan(
text: serviceName,
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 아이콘
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_forever_rounded,
color: Colors.red,
size: 40,
),
),
const SizedBox(height: 24),
const TextSpan(text: ' 구독을\n삭제하시겠습니까?'),
],
),
),
const SizedBox(height: 8),
// 타이틀
const Text(
'구독 삭제',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 12),
// 설명
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
height: 1.5,
),
children: [
const TextSpan(text: '정말로 '),
TextSpan(
text: serviceName,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const TextSpan(text: ' 구독을\n삭제하시겠습니까?'),
],
),
),
const SizedBox(height: 8),
// 경고 메시지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.red.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
const Text(
'이 작업은 되돌릴 수 없습니다',
style: TextStyle(
fontSize: 14,
color: Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
Expanded(
child: SecondaryButton(
text: '취소',
onPressed: () {
Navigator.of(context).pop(false);
},
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
text: '삭제',
icon: Icons.delete_rounded,
onPressed: () {
Navigator.of(context).pop(true);
},
backgroundColor: Colors.red,
),
),
],
),
],
),
// 경고 메시지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.error.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
Text(
'이 작업은 되돌릴 수 없습니다',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
Expanded(
child: SecondaryButton(
text: '취소',
onPressed: () {
Navigator.of(context).pop(false);
},
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
text: '삭제',
icon: Icons.delete_rounded,
onPressed: () {
Navigator.of(context).pop(true);
},
backgroundColor: Theme.of(context).colorScheme.error,
),
),
],
),
],
),
@@ -175,7 +158,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.5),
barrierColor: Theme.of(context).colorScheme.scrim.withValues(alpha: 0.5),
builder: (context) => DeleteConfirmationDialog(
serviceName: serviceName,
),

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;
import 'glassmorphism_card.dart';
// Glass 제거: Material 3 Card로 대체
import 'themed_text.dart';
import '../theme/app_colors.dart';
// import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
import '../utils/reduce_motion.dart';
@@ -17,118 +17,121 @@ class EmptyStateWidget extends StatelessWidget {
final VoidCallback onAddPressed;
const EmptyStateWidget({
Key? key,
super.key,
required this.fadeController,
required this.rotateController,
required this.slideController,
required this.onAddPressed,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
final beginOffset = ReduceMotion.isEnabled(context)
? const Offset(0, 0.05)
: 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(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
opacity: fade,
child: Center(
child: SlideTransition(
position: Tween<Offset>(
begin: beginOffset,
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutBack)),
position: slide,
child: RepaintBoundary(
child: GlassmorphismCard(
width: null,
child: Card(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: rotateController,
builder: (context, child) {
final angleScale =
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
return Transform.rotate(
angle:
angleScale * rotateController.value * 2 * math.pi,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.blueGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: rotateController,
builder: (context, child) {
final angleScale =
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
return Transform.rotate(
angle:
angleScale * rotateController.value * 2 * math.pi,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.subscriptions_outlined,
size: 48,
color: Theme.of(context).colorScheme.onPrimary,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.primaryColor
.withValues(alpha: 0.3),
spreadRadius: 0,
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.subscriptions_outlined,
size: 48,
color: AppColors.pureWhite,
),
),
);
},
),
const SizedBox(height: 32),
ThemedText(
AppLocalizations.of(context).noSubscriptions,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
const SizedBox(height: 8),
ThemedText(
AppLocalizations.of(context).addSubscriptionNow,
fontSize: 16,
opacity: 0.7,
),
const SizedBox(height: 32),
MouseRegion(
onEnter: (_) => {},
onExit: (_) => {},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
backgroundColor: AppColors.primaryColor,
),
onPressed: () {
HapticFeedback.mediumImpact();
onAddPressed();
);
},
child: Text(
AppLocalizations.of(context).addSubscription,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: AppColors.pureWhite,
),
const SizedBox(height: 32),
ThemedText(
AppLocalizations.of(context).noSubscriptions,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
const SizedBox(height: 8),
ThemedText(
AppLocalizations.of(context).addSubscriptionNow,
fontSize: 16,
opacity: 0.7,
),
const SizedBox(height: 32),
MouseRegion(
onEnter: (_) {},
onExit: (_) {},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onPrimary,
backgroundColor:
Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
onPressed: () {
HapticFeedback.mediumImpact();
onAddPressed();
},
child: Text(
AppLocalizations.of(context).addSubscription,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
),
],
],
),
),
),
),

View File

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

View File

@@ -1,316 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:math' as math;
import '../theme/app_colors.dart';
import 'floating_navigation_bar.dart';
/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드
class GlassmorphicScaffold extends StatefulWidget {
final PreferredSizeWidget? appBar;
final Widget body;
final Widget? floatingActionButton;
final FloatingActionButtonLocation? floatingActionButtonLocation;
final List<Color>? backgroundGradient;
final bool extendBodyBehindAppBar;
final bool extendBody;
final Widget? bottomNavigationBar;
final bool useFloatingNavBar;
final int? floatingNavBarIndex;
final Function(int)? onFloatingNavBarTapped;
final bool resizeToAvoidBottomInset;
final Widget? drawer;
final Widget? endDrawer;
final Color? backgroundColor;
final bool enableParticles;
final bool enableWaveAnimation;
const GlassmorphicScaffold({
super.key,
this.appBar,
required this.body,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.backgroundGradient,
this.extendBodyBehindAppBar = true,
this.extendBody = true,
this.bottomNavigationBar,
this.useFloatingNavBar = false,
this.floatingNavBarIndex,
this.onFloatingNavBarTapped,
this.resizeToAvoidBottomInset = true,
this.drawer,
this.endDrawer,
this.backgroundColor,
this.enableParticles = false,
this.enableWaveAnimation = false,
});
@override
State<GlassmorphicScaffold> createState() => _GlassmorphicScaffoldState();
}
class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
with TickerProviderStateMixin {
late AnimationController _particleController;
late AnimationController _waveController;
ScrollController? _scrollController;
bool _isFloatingNavBarVisible = true;
@override
void initState() {
super.initState();
_particleController = AnimationController(
duration: const Duration(seconds: 20),
vsync: this,
)..repeat();
_waveController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
)..repeat();
if (widget.useFloatingNavBar) {
_scrollController = ScrollController();
_setupScrollListener();
}
}
void _setupScrollListener() {
_scrollController?.addListener(() {
final currentScroll = _scrollController!.position.pixels;
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
if (currentScroll > 50 &&
_scrollController!.position.userScrollDirection ==
ScrollDirection.reverse) {
if (_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = false);
}
} else if (_scrollController!.position.userScrollDirection ==
ScrollDirection.forward) {
if (!_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = true);
}
}
});
}
@override
void dispose() {
_particleController.dispose();
_waveController.dispose();
_scrollController?.dispose();
super.dispose();
}
List<Color> _getBackgroundGradient() {
if (widget.backgroundGradient != null) {
return widget.backgroundGradient!;
}
// 디폴트 그라디언트
return AppColors.mainGradient;
}
@override
Widget build(BuildContext context) {
final backgroundGradient = _getBackgroundGradient();
return Stack(
children: [
// 배경 그라디언트
_buildBackground(backgroundGradient),
// 파티클 효과 (선택적)
if (widget.enableParticles) _buildParticles(),
// 웨이브 애니메이션 (선택적)
if (widget.enableWaveAnimation) _buildWaveAnimation(),
// 메인 스캐폴드
Scaffold(
backgroundColor: widget.backgroundColor ?? Colors.transparent,
appBar: widget.appBar,
body: widget.body,
floatingActionButton: widget.floatingActionButton,
floatingActionButtonLocation: widget.floatingActionButtonLocation,
bottomNavigationBar: widget.bottomNavigationBar,
extendBodyBehindAppBar: widget.extendBodyBehindAppBar,
extendBody: widget.extendBody,
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
drawer: widget.drawer,
endDrawer: widget.endDrawer,
),
// 플로팅 네비게이션 바 (선택적)
if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null)
FloatingNavigationBar(
selectedIndex: widget.floatingNavBarIndex!,
isVisible: _isFloatingNavBarVisible,
onItemTapped: widget.onFloatingNavBarTapped ?? (_) {},
),
],
);
}
Widget _buildBackground(List<Color> gradientColors) {
return Positioned.fill(
child: Container(
color: AppColors.backgroundColor, // 베이스 색상 추가
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradientColors
.map((color) => color.withValues(alpha: 0.3))
.toList(),
),
),
),
),
);
}
Widget _buildParticles() {
return Positioned.fill(
child: AnimatedBuilder(
animation: _particleController,
builder: (context, child) {
final media = MediaQuery.maybeOf(context);
final reduce = media?.disableAnimations ?? false;
final count = reduce ? 10 : 30;
return CustomPaint(
painter: ParticlePainter(
animation: _particleController,
particleCount: count,
),
);
},
),
);
}
Widget _buildWaveAnimation() {
return Positioned(
bottom: 0,
left: 0,
right: 0,
height: 200,
child: AnimatedBuilder(
animation: _waveController,
builder: (context, child) {
return CustomPaint(
painter: WavePainter(
animation: _waveController,
waveColor: AppColors.secondaryColor.withValues(alpha: 0.1),
),
);
},
),
);
}
}
/// 파티클 페인터
class ParticlePainter extends CustomPainter {
final Animation<double> animation;
final int particleCount;
final List<Particle> particles = [];
ParticlePainter({
required this.animation,
this.particleCount = 50,
}) : super(repaint: animation) {
_initParticles();
}
void _initParticles() {
final random = math.Random();
for (int i = 0; i < particleCount; i++) {
particles.add(Particle(
x: random.nextDouble(),
y: random.nextDouble(),
size: random.nextDouble() * 3 + 1,
speed: random.nextDouble() * 0.5 + 0.1,
opacity: random.nextDouble() * 0.5 + 0.1,
));
}
}
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (final particle in particles) {
final progress = animation.value;
final y = (particle.y + progress * particle.speed) % 1.0;
paint.color = AppColors.pureWhite.withValues(alpha: particle.opacity);
canvas.drawCircle(
Offset(particle.x * size.width, y * size.height),
particle.size,
paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// 웨이브 페인터
class WavePainter extends CustomPainter {
final Animation<double> animation;
final Color waveColor;
WavePainter({
required this.animation,
required this.waveColor,
}) : super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = waveColor
..style = PaintingStyle.fill;
final path = Path();
final progress = animation.value;
path.moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) {
final y =
math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) *
20 +
size.height * 0.5;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// 파티클 데이터 클래스
class Particle {
final double x;
final double y;
final double size;
final double speed;
final double opacity;
Particle({
required this.x,
required this.y,
required this.size,
required this.speed,
required this.opacity,
});
}

View File

@@ -1,240 +0,0 @@
import 'package:flutter/material.dart';
import '../utils/logger.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
import '../utils/reduce_motion.dart';
import 'themed_text.dart';
class GlassmorphismCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final double borderRadius;
final double blur;
final double opacity;
final Color? backgroundColor;
final Gradient? gradient;
final Border? border;
final List<BoxShadow>? boxShadow;
final VoidCallback? onTap;
const GlassmorphismCard({
super.key,
required this.child,
this.padding,
this.margin,
this.width,
this.height,
this.borderRadius = 16.0,
this.blur = 10.0,
this.opacity = 0.1,
this.backgroundColor,
this.gradient,
this.border,
this.boxShadow,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
width: width,
height: height,
margin: margin,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: ReduceMotion.scale(context,
normal: blur, reduced: blur * 0.4),
sigmaY: ReduceMotion.scale(context,
normal: blur, reduced: blur * 0.4),
),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: backgroundColor ?? AppColors.glassCard,
gradient: gradient ??
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? AppColors.glassGradientDark
: AppColors.glassGradient,
),
borderRadius: BorderRadius.circular(borderRadius),
border: border ??
Border.all(
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder,
width: 1,
),
boxShadow: boxShadow ??
[
BoxShadow(
color: AppColors
.shadowBlack, // color.md: rgba(0,0,0,0.08)
blurRadius: ReduceMotion.scale(context,
normal: 20, reduced: 10),
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
),
child: GlassmorphicIndicator(
child: child,
),
),
),
),
),
),
);
}
}
// 애니메이션이 적용된 글래스모피즘 카드
class AnimatedGlassmorphismCard extends StatefulWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final double borderRadius;
final double blur;
final double opacity;
final Duration animationDuration;
final VoidCallback? onTap;
const AnimatedGlassmorphismCard({
super.key,
required this.child,
this.padding,
this.margin,
this.width,
this.height,
this.borderRadius = 16.0,
this.blur = 10.0,
this.opacity = 0.1,
this.animationDuration = const Duration(milliseconds: 200),
this.onTap,
});
@override
State<AnimatedGlassmorphismCard> createState() =>
_AnimatedGlassmorphismCardState();
}
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _blurAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_blurAnimation = Tween<double>(
begin: widget.blur,
end: widget.blur * 1.5,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
// onTap이 없으면 제스처 처리를 하지 않음
if (widget.onTap == null) {
return GlassmorphismCard(
padding: widget.padding,
margin: widget.margin,
width: widget.width,
height: widget.height,
borderRadius: widget.borderRadius,
blur: widget.blur,
opacity: widget.opacity,
onTap: null,
child: widget.child,
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown,
onTapUp: (details) {
_handleTapUp(details);
// onTap 콜백 실행
if (widget.onTap != null) {
Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!();
}
},
onTapCancel: _handleTapCancel,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final scaleValue = ReduceMotion.scale(context,
normal: _scaleAnimation.value, reduced: 1.0);
return Transform.scale(
scale: scaleValue,
child: GlassmorphismCard(
padding: widget.padding,
margin: widget.margin,
width: widget.width,
height: widget.height,
borderRadius: widget.borderRadius,
blur: ReduceMotion.scale(context,
normal: _blurAnimation.value, reduced: widget.blur),
opacity: widget.opacity,
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
child: widget.child,
),
);
},
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More