Compare commits
13 Commits
codex/fix-
...
2cd46a303e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cd46a303e | ||
|
|
a9f42f6f01 | ||
|
|
132ae758de | ||
|
|
cba7d082bd | ||
|
|
8cec03f181 | ||
|
|
7ace3afaf3 | ||
|
|
87f82546a4 | ||
|
|
e909ba59a4 | ||
|
|
3af9a1f839 | ||
|
|
44850a53cc | ||
|
|
a01d9092ba | ||
|
|
3d86316a2b | ||
|
|
55e3f67279 |
@@ -36,6 +36,7 @@ Sensitive Areas (require explicit approval)
|
|||||||
Operational Conventions
|
Operational Conventions
|
||||||
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
|
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
|
||||||
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
|
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
|
||||||
|
- Git push 후 공유하는 설명/보고는 반드시 한국어로 작성합니다.
|
||||||
- PR description template:
|
- PR description template:
|
||||||
- Summary: what/why
|
- Summary: what/why
|
||||||
- Changes: key files and decisions
|
- Changes: key files and decisions
|
||||||
|
|||||||
@@ -45,5 +45,5 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.READ_SMS" />
|
<uses-permission android:name="android.permission.READ_SMS" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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
|
<application
|
||||||
android:label="구독 관리"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
@@ -13,7 +19,9 @@
|
|||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
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
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
@@ -36,6 +44,20 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
android:value="ca-app-pub-6691216385521068~6638409932" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
5
android/app/src/main/res/values-ja/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">デジタル月額管理者</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
5
android/app/src/main/res/values-ko/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">디지털 월세 관리자</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
5
android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">数字月租管理器</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Digital Rent Manager</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.7.0" apply false
|
id("com.android.application") version "8.7.0" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 985 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 312 B |
|
Before Width: | Height: | Size: 439 B |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 558 B |
|
Before Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
@@ -29,6 +29,29 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"appLock": "App Lock",
|
"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",
|
"notificationPermission": "Notification Permission",
|
||||||
"notificationPermissionDesc": "Permission is required to receive notifications",
|
"notificationPermissionDesc": "Permission is required to receive notifications",
|
||||||
"requestPermission": "Request Permission",
|
"requestPermission": "Request Permission",
|
||||||
@@ -126,9 +149,11 @@
|
|||||||
"repeatSubscriptionNotFound": "No repeated subscription information found.",
|
"repeatSubscriptionNotFound": "No repeated subscription information found.",
|
||||||
"newSubscriptionNotFound": "No new subscription SMS found",
|
"newSubscriptionNotFound": "No new subscription SMS found",
|
||||||
"findRepeatSubscriptions": "Find subscriptions paid 2+ times",
|
"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",
|
"startScanning": "Start Scanning",
|
||||||
"foundSubscription": "Found subscription",
|
"foundSubscription": "Found subscription",
|
||||||
|
"latestSmsMessage": "Latest SMS message",
|
||||||
|
"smsDetectedDate": "Detected on @",
|
||||||
"serviceName": "Service Name",
|
"serviceName": "Service Name",
|
||||||
"nextBillingDateLabel": "Next Billing Date",
|
"nextBillingDateLabel": "Next Billing Date",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
@@ -147,6 +172,7 @@
|
|||||||
"estimatedAnnualCost": "Estimated Annual Cost",
|
"estimatedAnnualCost": "Estimated Annual Cost",
|
||||||
"totalSubscriptionServices": "Total Subscription Services",
|
"totalSubscriptionServices": "Total Subscription Services",
|
||||||
"eventDiscountActive": "Event Discount Active",
|
"eventDiscountActive": "Event Discount Active",
|
||||||
|
"eventDiscountEndsBeforeBilling": "Event discount ends before billing date",
|
||||||
"saving": "Saving",
|
"saving": "Saving",
|
||||||
"paymentDueToday": "Payment Due Today",
|
"paymentDueToday": "Payment Due Today",
|
||||||
"paymentDueInDays": "Payment due in @ days",
|
"paymentDueInDays": "Payment due in @ days",
|
||||||
@@ -199,7 +225,7 @@
|
|||||||
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
|
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
|
||||||
"goToCancelPage": "Go to Cancellation Page",
|
"goToCancelPage": "Go to Cancellation Page",
|
||||||
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
|
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
|
||||||
"discountPercent": "@% discount",
|
"discountPercent": "% discount",
|
||||||
"discountAmountWon": "Save ₩@",
|
"discountAmountWon": "Save ₩@",
|
||||||
"discountAmountDollar": "Save $@",
|
"discountAmountDollar": "Save $@",
|
||||||
"discountAmountYen": "Save ¥@",
|
"discountAmountYen": "Save ¥@",
|
||||||
@@ -259,6 +285,29 @@
|
|||||||
"language": "언어",
|
"language": "언어",
|
||||||
"notifications": "알림",
|
"notifications": "알림",
|
||||||
"appLock": "앱 잠금",
|
"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": "알림 권한",
|
"notificationPermission": "알림 권한",
|
||||||
"notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다",
|
"notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다",
|
||||||
"requestPermission": "권한 요청",
|
"requestPermission": "권한 요청",
|
||||||
@@ -356,9 +405,11 @@
|
|||||||
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
|
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
|
||||||
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
|
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
|
||||||
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
|
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
|
||||||
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.",
|
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
|
||||||
"startScanning": "스캔 시작하기",
|
"startScanning": "스캔 시작하기",
|
||||||
"foundSubscription": "다음 구독을 찾았습니다",
|
"foundSubscription": "다음 구독을 찾았습니다",
|
||||||
|
"latestSmsMessage": "최신 SMS 메시지",
|
||||||
|
"smsDetectedDate": "SMS 수신일: @",
|
||||||
"serviceName": "서비스명",
|
"serviceName": "서비스명",
|
||||||
"nextBillingDateLabel": "다음 결제일",
|
"nextBillingDateLabel": "다음 결제일",
|
||||||
"category": "카테고리",
|
"category": "카테고리",
|
||||||
@@ -377,6 +428,7 @@
|
|||||||
"estimatedAnnualCost": "예상 연간 구독 비용",
|
"estimatedAnnualCost": "예상 연간 구독 비용",
|
||||||
"totalSubscriptionServices": "총 구독 서비스",
|
"totalSubscriptionServices": "총 구독 서비스",
|
||||||
"eventDiscountActive": "이벤트 할인 중",
|
"eventDiscountActive": "이벤트 할인 중",
|
||||||
|
"eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다",
|
||||||
"saving": "절약",
|
"saving": "절약",
|
||||||
"paymentDueToday": "오늘 결제 예정",
|
"paymentDueToday": "오늘 결제 예정",
|
||||||
"paymentDueInDays": "@일 후 결제 예정",
|
"paymentDueInDays": "@일 후 결제 예정",
|
||||||
@@ -429,7 +481,7 @@
|
|||||||
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
|
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
|
||||||
"goToCancelPage": "해지 페이지로 이동",
|
"goToCancelPage": "해지 페이지로 이동",
|
||||||
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
|
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
|
||||||
"discountPercent": "@% 할인",
|
"discountPercent": "% 할인",
|
||||||
"discountAmountWon": "₩@원 절약",
|
"discountAmountWon": "₩@원 절약",
|
||||||
"discountAmountDollar": "$@ 절약",
|
"discountAmountDollar": "$@ 절약",
|
||||||
"discountAmountYen": "¥@ 절약",
|
"discountAmountYen": "¥@ 절약",
|
||||||
@@ -489,6 +541,29 @@
|
|||||||
"language": "言語",
|
"language": "言語",
|
||||||
"notifications": "通知",
|
"notifications": "通知",
|
||||||
"appLock": "アプリロック",
|
"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": "通知権限",
|
"notificationPermission": "通知権限",
|
||||||
"notificationPermissionDesc": "通知を受け取るには権限が必要です",
|
"notificationPermissionDesc": "通知を受け取るには権限が必要です",
|
||||||
"requestPermission": "権限をリクエスト",
|
"requestPermission": "権限をリクエスト",
|
||||||
@@ -586,9 +661,11 @@
|
|||||||
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
|
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
|
||||||
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
|
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
|
||||||
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
|
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
|
||||||
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。サービス名と金額を抽出して簡単にサブスクリプションを追加できます。",
|
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
|
||||||
"startScanning": "スキャン開始",
|
"startScanning": "スキャン開始",
|
||||||
"foundSubscription": "サブスクリプションが見つかりました",
|
"foundSubscription": "サブスクリプションが見つかりました",
|
||||||
|
"latestSmsMessage": "最新のSMSメッセージ",
|
||||||
|
"smsDetectedDate": "SMS受信日: @",
|
||||||
"serviceName": "サービス名",
|
"serviceName": "サービス名",
|
||||||
"nextBillingDateLabel": "次回請求日",
|
"nextBillingDateLabel": "次回請求日",
|
||||||
"category": "カテゴリー",
|
"category": "カテゴリー",
|
||||||
@@ -607,6 +684,7 @@
|
|||||||
"estimatedAnnualCost": "予想年間サブスクリプション費用",
|
"estimatedAnnualCost": "予想年間サブスクリプション費用",
|
||||||
"totalSubscriptionServices": "総サブスクリプションサービス",
|
"totalSubscriptionServices": "総サブスクリプションサービス",
|
||||||
"eventDiscountActive": "イベント割引中",
|
"eventDiscountActive": "イベント割引中",
|
||||||
|
"eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します",
|
||||||
"saving": "節約",
|
"saving": "節約",
|
||||||
"paymentDueToday": "本日支払い予定",
|
"paymentDueToday": "本日支払い予定",
|
||||||
"paymentDueInDays": "@日後に支払い予定",
|
"paymentDueInDays": "@日後に支払い予定",
|
||||||
@@ -659,7 +737,7 @@
|
|||||||
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
|
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
|
||||||
"goToCancelPage": "解約ページへ移動",
|
"goToCancelPage": "解約ページへ移動",
|
||||||
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
|
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
|
||||||
"discountPercent": "@%割引",
|
"discountPercent": "%割引",
|
||||||
"discountAmountWon": "₩@節約",
|
"discountAmountWon": "₩@節約",
|
||||||
"discountAmountDollar": "$@節約",
|
"discountAmountDollar": "$@節約",
|
||||||
"discountAmountYen": "¥@節約",
|
"discountAmountYen": "¥@節約",
|
||||||
@@ -708,6 +786,29 @@
|
|||||||
"language": "语言",
|
"language": "语言",
|
||||||
"notifications": "通知",
|
"notifications": "通知",
|
||||||
"appLock": "应用锁定",
|
"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": "通知权限",
|
"notificationPermission": "通知权限",
|
||||||
"notificationPermissionDesc": "需要权限才能接收通知",
|
"notificationPermissionDesc": "需要权限才能接收通知",
|
||||||
"requestPermission": "请求权限",
|
"requestPermission": "请求权限",
|
||||||
@@ -805,9 +906,11 @@
|
|||||||
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
|
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
|
||||||
"newSubscriptionNotFound": "未找到新订阅短信",
|
"newSubscriptionNotFound": "未找到新订阅短信",
|
||||||
"findRepeatSubscriptions": "查找支付2次以上的订阅",
|
"findRepeatSubscriptions": "查找支付2次以上的订阅",
|
||||||
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。可以提取服务名称和金额,轻松添加订阅。",
|
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额,轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果,并在需要时手动添加或修改。",
|
||||||
"startScanning": "开始扫描",
|
"startScanning": "开始扫描",
|
||||||
"foundSubscription": "找到订阅",
|
"foundSubscription": "找到订阅",
|
||||||
|
"latestSmsMessage": "最新短信内容",
|
||||||
|
"smsDetectedDate": "短信接收日期:@",
|
||||||
"serviceName": "服务名称",
|
"serviceName": "服务名称",
|
||||||
"nextBillingDateLabel": "下次付款日期",
|
"nextBillingDateLabel": "下次付款日期",
|
||||||
"category": "类别",
|
"category": "类别",
|
||||||
@@ -826,6 +929,7 @@
|
|||||||
"estimatedAnnualCost": "预计年度订阅费用",
|
"estimatedAnnualCost": "预计年度订阅费用",
|
||||||
"totalSubscriptionServices": "总订阅服务",
|
"totalSubscriptionServices": "总订阅服务",
|
||||||
"eventDiscountActive": "活动折扣中",
|
"eventDiscountActive": "活动折扣中",
|
||||||
|
"eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束",
|
||||||
"saving": "节省",
|
"saving": "节省",
|
||||||
"paymentDueToday": "今日付款到期",
|
"paymentDueToday": "今日付款到期",
|
||||||
"paymentDueInDays": "@天后付款到期",
|
"paymentDueInDays": "@天后付款到期",
|
||||||
@@ -878,7 +982,7 @@
|
|||||||
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
|
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
|
||||||
"goToCancelPage": "前往取消页面",
|
"goToCancelPage": "前往取消页面",
|
||||||
"urlAutoMatchInfo": "如果URL为空,将根据服务名称自动匹配",
|
"urlAutoMatchInfo": "如果URL为空,将根据服务名称自动匹配",
|
||||||
"discountPercent": "@%折扣",
|
"discountPercent": "%折扣",
|
||||||
"discountAmountWon": "节省₩@",
|
"discountAmountWon": "节省₩@",
|
||||||
"discountAmountDollar": "节省$@",
|
"discountAmountDollar": "节省$@",
|
||||||
"discountAmountYen": "节省¥@",
|
"discountAmountYen": "节省¥@",
|
||||||
|
|||||||
253
doc/color.md
@@ -1,79 +1,208 @@
|
|||||||
# 구독관리 앱 글래스모피어즘 컬러 & 텍스트 컬러 가이드
|
# SubManager 컬러/테마 가이드 v4 (Glass 제거, 완전 Material 3)
|
||||||
|
|
||||||
구독관리 앱에 글래스모피어즘을 적용할 때, **신뢰성, 편안함, 트렌드함**을 모두 잡으면서도 **텍스트 가독성**을 최우선으로 고려한 컬러 팔레트와 활용법을 안내합니다.
|
목표: 글래스모피어즘(반투명/블러/그라데이션)을 전면 제거하고, 전 화면/버튼/팝업을 Material 3(ColorScheme/typography/shape/elevation) 기준으로 재정렬합니다. 버튼 나열 UI를 드롭다운으로 바꾸지 않습니다. 설정 화면에 라이트/다크/시스템 모드 선택 UI를 추가합니다.
|
||||||
|
|
||||||
## 1. 컬러 팔레트 제안
|
## 0) 현재 상태 진단(요약)
|
||||||
|
- 전역 테마: M3 사용 중(`useMaterial3: true`). 라이트/다크/OLED/고대비 테마 존재.
|
||||||
|
- 이슈: `ColorScheme.error`가 핑크(danger)에 매핑 → 오류색으로 부적합(레드 필요).
|
||||||
|
- Glass 사용처 다수(요약/분석/네비/빈상태 등): 반투명+블러+경계. 다크/저성능 장치에서 가독성·성능 저하 가능.
|
||||||
|
- 곳곳의 하드코딩 텍스트 컬러(`AppColors.darkNavy`, `Color(0xFF...)`) 존재 → 다크에서 대비 문제 소지.
|
||||||
|
|
||||||
| 용도 | 컬러명 | Hex 코드 | 설명/느낌 |
|
## 1) 원칙(신뢰·접근성·일관성)
|
||||||
|--------------|--------------|--------------|--------------------------|
|
- 신뢰: Primary는 딥 블루(#2563EB). 과장된 장식 대신 명확한 위계/역할색 사용.
|
||||||
| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 |
|
- 접근성: 본문 대비 WCAG AA(4.5:1) 충족. on-colors(onPrimary/onSurface/onError…) 일관 적용.
|
||||||
| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 |
|
- 일관성: 전역 ColorScheme/typography/shape/elevation 우선, 로컬 styleFrom 최소화.
|
||||||
| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 |
|
- 성능/가독성: Glass 제거 → 불투명 Surface + elevation/outline 중심으로 레이어 구분.
|
||||||
| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 |
|
|
||||||
| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 |
|
|
||||||
| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 |
|
|
||||||
| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 |
|
|
||||||
|
|
||||||
## 2. 텍스트 색상 가이드
|
## 2) 팔레트(최종)
|
||||||
|
- Primary: #2563EB / onPrimary: #FFFFFF
|
||||||
|
- Secondary: #60A5FA / onSecondary: #0B1B31(또는 onSurface)
|
||||||
|
- Tertiary(Info): #6366F1 / onTertiary: #FFFFFF
|
||||||
|
- Error: #EF4444 / onError: #FFFFFF
|
||||||
|
- Success: #22C55E / Warning: #F59E0B (둘은 ColorScheme 외 확장 토큰으로 관리)
|
||||||
|
- Light: Background #F1F5F9 / Surface #FFFFFF / SurfaceVariant #F8FAFC / OnSurface #1E293B / OnSurfaceVariant #334155 / Outline #E2E8F0
|
||||||
|
- Dark: Background #121212 / Surface #1E1E1E / OnSurface #F5F5F6 / OnSurfaceVariant #94A3B8 / Outline #3F3F46
|
||||||
|
|
||||||
밝은 배경(예: #f1f5f9, #ffffff(투명)) 위에는 **어두운 텍스트**를,
|
## 3) 타입·라디우스·간격·음영 스케일
|
||||||
진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다.
|
- Typography(권장):
|
||||||
|
- displayLarge 48 / displayMedium 40 / displaySmall 34
|
||||||
|
- headlineLarge 32 / headlineMedium 28 / headlineSmall 24
|
||||||
|
- titleLarge 20 / titleMedium 18 / titleSmall 16
|
||||||
|
- bodyLarge 16 / bodyMedium 14 / bodySmall 12
|
||||||
|
- labelLarge 14 / labelMedium 12 / labelSmall 11
|
||||||
|
- Line-height: 1.3~1.5, Letter-spacing: 헤드라인(-0.2~-0.5), 본문(+0.1)
|
||||||
|
- Shape: 4(칩/태그) / 8(스위치/토글) / 12(버튼/입력) / 16(카드/시트)
|
||||||
|
- Elevation: 0(평면) / 1(구분) / 3(카드) / 6(상부 시트/다이얼로그)
|
||||||
|
- Spacing: 4 단위(8/12/16/24/32)로 수직 리듬 고정
|
||||||
|
|
||||||
| 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 |
|
## 4) Glass 제거 및 대체 규칙
|
||||||
|------------------|----------------------|-----------------------------------|
|
- `lib/widgets/glassmorphism_card.dart` 사용부 전면 치환:
|
||||||
| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 |
|
- 대체: `Card(elevation: 3, color: colorScheme.surface, shape: 16)`
|
||||||
| White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 |
|
- 경계: `Outline` 기반(라이트 #E2E8F0, 다크 #3F3F46, 투명도 60~80%)
|
||||||
| Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 |
|
- 섀도우: 라이트만 약하게(8~12), 다크는 outline 위주
|
||||||
| Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 |
|
- 내부 텍스트: 항상 `colorScheme.onSurface` 또는 전역 `textTheme` 사용(하드코딩 금지)
|
||||||
| Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 |
|
- 그라데이션/반투명 배경 삭제(필요 시 Hero/그림·아이콘 등으로 시각적 흥미 보완)
|
||||||
| Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 |
|
|
||||||
|
|
||||||
## 3. 실전 적용 예시
|
## 5) 컴포넌트별 가이드(누락 없음)
|
||||||
|
- AppBar: 배경=surface, 제목/아이콘=onSurface, 높이=56, 타이틀 글꼴=titleLarge
|
||||||
|
- Navigation(하단): 배경=surface, 활성 아이콘/라벨=primary, 비활성=onSurfaceVariant, 반경=16
|
||||||
|
- FAB: 배경=primary, 아이콘=onPrimary, 반경=16, elevation=6
|
||||||
|
- Buttons(Elevated/Text/Outlined): minHeight=48, 반경=12, primary=onPrimary, outline=outline, text=onSurface
|
||||||
|
- IconButton: 기본 onSurface, 강조 상태는 primary 80~90%
|
||||||
|
- Inputs(TextField/Selectors): filled 라이트=surfaceVariant, 다크=#2A2A2A, 포커스라인=primary 1.5, 에러=error 1.5~2
|
||||||
|
- Chips/Badges: 배경=역할색(primary/success/warning/error), 텍스트=onX, 반경=8
|
||||||
|
- Cards: elevation=3, 반경=16, 배경=surface, 텍스트=onSurface
|
||||||
|
- Lists/Tiles: 제목=onSurface, 보조=onSurfaceVariant, divider=outline, 타일 반경=12
|
||||||
|
- Dialogs/Sheets: 배경=surface, 제목=titleLarge, 본문=bodyMedium, 버튼=역할색+onX, elevation=6, 반경=20
|
||||||
|
- Snackbar: 배경=역할색(primary/success/warning/error), 텍스트/아이콘=onX, 모서리=12, floating
|
||||||
|
- Tooltips: 배경=onSurface, 텍스트=surface, 반경=8
|
||||||
|
- Progress: primary 사용, 트랙=onSurfaceVariant
|
||||||
|
- Charts/Analysis: 팔레트 [primary, tertiary(info), success, warning, error, secondary], 라벨=onSurface
|
||||||
|
- Categories/SMS: 카테고리 배경 위 텍스트/아이콘은 대비 계산(white 또는 onSurface) 적용
|
||||||
|
|
||||||
- **배경**: Light Gray (#f1f5f9)
|
## 6) 설정 화면에 모드 선택 UI 추가(계획)
|
||||||
- **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb)
|
- 위치: `lib/screens/settings_screen.dart`
|
||||||
- **메인 텍스트**: Dark Navy (#1e293b)
|
- 섹션명: Appearance(또는 테마)
|
||||||
- **서브/설명 텍스트**: Navy Gray (#334155)
|
- 구성: `Theme Mode` 라디오 그룹(시스템 / 라이트 / 다크)
|
||||||
- **버튼 배경**: Deep Blue (#2563eb)
|
- RadioListTile 3개(버튼 나열 유지, 드롭다운 금지)
|
||||||
- **버튼 텍스트**: Pure White (#ffffff)
|
- 값: `AppThemeMode.system|light|dark`
|
||||||
- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6)
|
- 동작: `context.read<ThemeProvider>().setThemeMode(mode)` 호출
|
||||||
|
- 추가 토글(유지): 큰 텍스트/모션 감소/고대비(현 Provider 연동)
|
||||||
## 4. 그라디언트 및 글래스 효과 예시
|
|
||||||
|
|
||||||
|
샘플 코드
|
||||||
```dart
|
```dart
|
||||||
// Flutter 예시 (Dart)
|
final themeProvider = context.read<ThemeProvider>();
|
||||||
LinearGradient(
|
Column(children: [
|
||||||
begin: Alignment.topLeft,
|
ListTile(title: Text('Theme Mode')),
|
||||||
end: Alignment.bottomRight,
|
RadioListTile(
|
||||||
colors: [
|
title: Text('System'),
|
||||||
Color(0xFF2563eb),
|
value: AppThemeMode.system,
|
||||||
Color(0xFF60a5fa),
|
groupValue: themeProvider.themeMode,
|
||||||
Color(0xFFe0e7ef),
|
onChanged: (v) => themeProvider.setThemeMode(v!),
|
||||||
],
|
),
|
||||||
)
|
RadioListTile(
|
||||||
|
title: Text('Light'),
|
||||||
|
value: AppThemeMode.light,
|
||||||
|
groupValue: themeProvider.themeMode,
|
||||||
|
onChanged: (v) => themeProvider.setThemeMode(v!),
|
||||||
|
),
|
||||||
|
RadioListTile(
|
||||||
|
title: Text('Dark'),
|
||||||
|
value: AppThemeMode.dark,
|
||||||
|
groupValue: themeProvider.themeMode,
|
||||||
|
onChanged: (v) => themeProvider.setThemeMode(v!),
|
||||||
|
),
|
||||||
|
]);
|
||||||
```
|
```
|
||||||
- 글래스 카드 배경: rgba(255,255,255,0.2) + blur + border(Deep Blue)
|
|
||||||
- 텍스트: #1e293b(진한 네이비) 또는 #2563eb(딥블루) 사용
|
|
||||||
|
|
||||||
## 5. 디자인 팁
|
## 7) 적용 순서(리스크 최소)
|
||||||
|
1) 전역 스킴 교정: `ColorScheme.error` 레드로, textTheme onSurface 정렬
|
||||||
|
2) Glass 제거: `GlassmorphismCard` → `Card` 치환(화면 단위 PR: 홈→분석→설정→세부)
|
||||||
|
3) 버튼/입력/스낵바/다이얼로그 on-colors 정렬, 하드코딩 텍스트 제거
|
||||||
|
4) 모드 선택 UI 추가(설정 화면 라디오 그룹)
|
||||||
|
5) 카테고리/차트 대비 보정 유틸 적용
|
||||||
|
6) 회귀·접근성 검증(라이트/다크/시스템)
|
||||||
|
|
||||||
- **텍스트 대비**를 항상 체크하세요.
|
## 8) 검증
|
||||||
밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트!
|
- 스크립트: `scripts/check.sh` (format/analyze/test)
|
||||||
- **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다.
|
- 시각: 모든 화면에서 텍스트 대비(AA) 확인, 상태(Hover/Pressed/Disabled) 점검
|
||||||
- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요.
|
- 성능: Glass 제거 후 저사양 단말 스크롤/애니메이션 프레임 확인
|
||||||
|
|
||||||
## 6. 컬러/텍스트 조합 요약표
|
## 9) 요약
|
||||||
|
- Glass 제거 + 완전 Material 3 전환으로 신뢰감, 가독성, 성능을 함께 강화합니다.
|
||||||
|
- 오류색은 레드로 통일, on-colors로 대비를 보장합니다.
|
||||||
|
- 설정에 시스템/라이트/다크 선택을 제공하고, 버튼 나열 UI는 유지합니다.
|
||||||
|
|
||||||
| 배경색 | 텍스트색 | 용도 예시 |
|
## 진행 현황(Work Log)
|
||||||
|------------------|------------------|--------------------|
|
- [완료] 전역 스킴 교정: `ColorScheme.error`를 레드(#EF4444)로 교정 (라이트/다크)
|
||||||
| #f1f5f9 | #1e293b | 메인 타이틀, 내용 |
|
- [완료] 스낵바 오류색 정렬: `AppSnackBar.showError`가 `colorScheme.error` 사용
|
||||||
| #ffffff(투명) | #2563eb | 카드 내 강조 |
|
- [완료] 설정 화면 테마 모드 UI: System/Light/Dark SegmentedButton 추가(드롭다운/라디오 대체, M3 준수)
|
||||||
| #2563eb | #ffffff | 버튼, 반전 강조 |
|
- [완료] Glass 제거(설정 화면): `GlassmorphismCard` → `Card` 치환
|
||||||
| #60a5fa | #334155 | 서브, 설명 |
|
- [완료] Glass 제거(빈 상태 위젯): `EmptyStateWidget`를 `Card` 기반으로 재구성
|
||||||
| #38bdf8 | #334155 | 포인트, 서브텍스트 |
|
- [완료] Glass 제거(홈 요약 카드): `MainScreenSummaryCard` 외곽 → `Card`
|
||||||
|
- [완료] Glass 제거(분석 카드): 월간 지출/총지출/파이차트 카드 → `Card`
|
||||||
|
- [완료] Glass 제거(광고 카드): `NativeAdWidget` → `Card`
|
||||||
|
- [완료] Glass 제거(추가 폼 섹션): `AddSubscriptionForm` → `Card`
|
||||||
|
- [완료] Glass 제거(SMS 권한 화면): 설명 카드 → `Card`
|
||||||
|
- [완료] Glass 제거(네비게이션): Floating Navigation Bar → Container + Padding(Material 기준)
|
||||||
|
- [완료] Glass 제거(메인 스캐폴드): `GlassmorphicScaffold` → Stack+Scaffold(배경 그라디언트+M3)
|
||||||
|
- [진행] Glass 제거(기타): 일부 카드(예: SubscriptionCard) 잔여 사용처 점진 치환 예정
|
||||||
|
- [완료] Glass 제거(구독 카드): SubscriptionCard 래퍼를 Material Card+InkWell로 대체
|
||||||
|
- [진행] 하드코딩 텍스트 컬러 제거: 메인 요약/URL 섹션/네비/홈 로딩 인디케이터 등 onSurface/onSurfaceVariant로 정렬
|
||||||
|
- [진행] 하드코딩 컬러 정리(추가): 카테고리 관리/앱 잠금/이벤트·URL 상세 섹션 컨테이너와 텍스트를 M3(`surface`, `outline`, `onSurface`)로 정렬
|
||||||
|
- [진행] 폼/셀렉터 M3 정렬: DatePickerField/CurrencySelector 색을 `onSurface`/`primary`/`surfaceVariant`로 통일
|
||||||
|
- [진행] Selectors: Category/BillingCycle 선택 컴포넌트의 배경/텍스트를 `primary`/`onSurface`로 정렬
|
||||||
|
- [진행] 공통 입력/라벨: BaseTextField/DatePickerField 라벨·힌트·값을 `onSurface`/`onSurfaceVariant`로 정렬
|
||||||
|
- [진행] 삭제 다이얼로그: Glass 제거, Material Dialog(표면/elevation) + on-colors 적용
|
||||||
|
- [진행] 추가 화면: 이벤트 섹션 타이틀/설명을 onSurface로 정렬
|
||||||
|
- [진행] 날짜 필드(DatePicker/Range): 라벨/값/아이콘/컨테이너를 M3 surface/outline/onSurface 계열로 치환
|
||||||
|
- [진행] 분석 카드/리스트: 보조 텍스트/경계/아이콘을 onSurfaceVariant/primary 계열로 정리
|
||||||
|
- [진행] 설정 화면: 텍스트/아이콘 색을 onSurface/onSurfaceVariant로 정리
|
||||||
|
- [진행] SMS 권한 화면: 아이콘/제목/본문을 primary/onSurface/onSurfaceVariant로 정리
|
||||||
|
- [진행] 추가 화면 AppBar/저장 버튼: 색을 onSurface/primary로 정리
|
||||||
|
- [다음] 버튼/입력/다이얼로그/스낵바의 on-colors 재점검 및 하드코딩 텍스트 컬러 제거
|
||||||
|
|
||||||
|
### 2025-09-10 작업 메모(Incremental)
|
||||||
|
- [완료] Settings 화면: `AppColors.*` 제거 → `colorScheme.primary/onSurface/onSurfaceVariant` 적용. 알림 반복 SwitchListTile의 `activeColor` 비사용(신 API `activeThumbColor/activeTrackColor`)로 교체.
|
||||||
|
- [완료] AddSubscriptionForm: CurrencySelector / BillingCycleSelector / CategorySelector의 `isGlassmorphism` 플래그 비활성(기본 M3 경량 스타일 사용).
|
||||||
|
- [완료] MainSummaryCard: 이벤트 절약액 텍스트 색상을 `colorScheme.primary`로 정렬.
|
||||||
|
- [완료] MonthlyExpenseChartCard: 툴팁 배경/텍스트를 `inverseSurface/onInverseSurface`로 교체(가독성 향상).
|
||||||
|
- [완료] Light Theme 카드/입력: `lib/theme/app_theme.dart`의 카드 테마에서 글래스 컬러/보더 제거, elevation=1·radius=16 유지. InputDecorationTheme는 `surfaceVariant`(light 대체 토큰) + `outline/primary/error` 경계로 전환.
|
||||||
|
- [완료] TotalExpenseSummaryCard: 아이콘 캡슐 배경을 `surfaceContainerHighest`+`outline`로 교체, 아이콘 컬러는 `primary` 사용. 복사 스낵바의 글래스 배경 제거.
|
||||||
|
- [완료] DetailFormSection: 글래스 박스 → `surface` + `outline` 컨테이너로 교체, Currency/BillingCycle/Category 셀렉터의 `isGlassmorphism` 비활성.
|
||||||
|
- [완료] SMS Scan SubscriptionCard: `Card(elevation:1, outline)`로 교체, forceDark 텍스트 제거, 입력 `fillColor`를 `surface`로 통일, 카테고리 셀렉터 글래스 비활성.
|
||||||
|
- [완료] SecondaryButton: Hover 배경을 `onSurface` 6%로, 보더/텍스트를 `outline/primary`로 정렬.
|
||||||
|
- [완료] CategoryManagement: AppBar `primary/onPrimary` 적용, Dropdown `value→initialValue`(비권장 API 해결), 텍스트 onSurface 정렬.
|
||||||
|
- [완료] Primary/SecondaryButton hover 트랜스폼: `Matrix4.scale` 제거 → `diagonal3Values` 또는 `Transform.scale`로 대체(비권장 API 해결).
|
||||||
|
- [완료] RotatePageRoute 전환: `Matrix4.scale` 제거 → 중첩 `Transform.scale`로 전환.
|
||||||
|
- [완료] ThemedText: AppColors 의존 제거, 대비 색상 결정을 `colorScheme.onSurface` 기반으로 단순화.
|
||||||
|
- [완료] 글래스 파일 제거: `lib/widgets/glassmorphism_card.dart`, `lib/widgets/glassmorphic_scaffold.dart` 삭제(미참조 확인).
|
||||||
|
- [완료] Light Theme 텍스트·컴포넌트 정렬: `app_theme.dart`에서 textTheme를 M3 기본 + `onSurface` 컬러로 일괄 정렬. Switch/Checkbox/Radio/Slider/TabBar/Divider를 `ColorScheme` 기반으로 리팩터.
|
||||||
|
- [완료] AddSubscriptionAppBar: const 적용(경고 제거), `scripts/check.sh` 전체 통과 확인.
|
||||||
|
- [완료] Dark/OLED 테마 정리: `adaptive_theme.dart`에서 다크 텍스트·컴포넌트(M3 on-colors) 정렬, Input/Buttons/TabBar/Divider/Switch/Checkbox/Radio/Slider를 ColorScheme 기준으로 통일. OLED는 surface/배경만 블랙 톤으로 보정.
|
||||||
|
- [완료] ThemedText: Glass 마커 제거(Indicator/Wrapper 삭제), 대비 로직 단순화.
|
||||||
|
- [완료] Charts: 월간 바차트 색상 `ColorScheme.primary/secondary`로 전환, 그리드/백바 `onSurfaceVariant` 사용. 파이차트 팔레트는 `ColorScheme(primary/secondary/tertiary/error)+success/warning 상수`로 정리.
|
||||||
|
- [완료] Settings/SubscriptionCard: 글래스 위젯 의존 제거 → Material Card + InkWell로 치환(중첩 Padding은 ListTile의 `contentPadding` 사용).
|
||||||
|
- [완료] Settings 색 정리 마무리: 모든 텍스트/아이콘/보더/드롭다운을 `onSurface/onSurfaceVariant/primary/surface`로 통일.
|
||||||
|
- [완료] 전역 그라데이션 제거: EmptyState/FloatingNav Add/MainSummary 이벤트 배지/Detail Header/Detail 편집 안내/SubscriptionCard 헤더·이벤트 배지/Add 화면 헤더/Splash 배경, 로고/파티클 장식 등 모든 Linear/Radial gradient 삭제. 단색은 `primary`/`surface`/`surfaceContainer*`/semantic(error, warning)로 대체.
|
||||||
|
- [완료] 차트 막대 그라데이션 제거: 단색 `primary`로 통일.
|
||||||
|
- [검증] `scripts/check.sh` 실행: 포맷 자동 적용 후 정적 분석 info 수준 경고만 존재(주요 `activeColor` 비권장 항목 해결됨).
|
||||||
|
|
||||||
## 결론
|
### 2025-09-11 작업 메모(Incremental)
|
||||||
|
- [완료] BillingCycleSelector: 선택 배경=primary, 텍스트=onPrimary, 비선택 배경=surface, 보더=outline(60%); glass/gradient 파라미터는 비사용 처리(호환 유지).
|
||||||
|
- [완료] CurrencySelector: 동일한 M3 패턴으로 정리(표면/윤곽선/온컬러), isGlassmorphism 무시.
|
||||||
|
- [완료] CategorySelector: 선택 시 baseColor가 있으면 사용, 없으면 primary; 나머지는 surface/outline/onSurface.
|
||||||
|
- [완료] AnalysisBadge: AppColors 제거 → surface 배경 + outline 보더 + 은은한 블랙 섀도(8%).
|
||||||
|
- [완료] SubscriptionCard:
|
||||||
|
- 상단 스트립: event=error, 결제 임박=warning, 그 외=카테고리 색.
|
||||||
|
- 가격: 이벤트 원가=onSurfaceVariant 취소선, 현재가=error, 일반가=primary.
|
||||||
|
- 결제 예정 뱃지: success/warning(확장 토큰) 사용, 배경은 10% 알파.
|
||||||
|
- 결제 주기 뱃지: surface + outline, 텍스트 onSurfaceVariant.
|
||||||
|
- [검증] `scripts/check.sh` 전체 통과(Format/Analyze/Test OK).
|
||||||
|
|
||||||
- **블루+화이트+민트** 조합과, **밝은 배경+어두운 텍스트** 원칙으로 신뢰성, 편안함, 트렌드함, 가독성 모두 챙길 수 있습니다.
|
### 2025-09-11 추가 배치
|
||||||
- 실제 앱에 적용할 때는 위 표를 참고해 각 상황별로 텍스트 컬러를 꼭 맞춰주세요.
|
- [완료] AppLock/Main 화면 스낵바: ColorScheme.error/success + onPrimary 텍스트로 통일.
|
||||||
- 글래스모피어즘 효과와 대비 높은 텍스트 조합으로, 세련되고 사용성 좋은 구독관리 앱을 완성할 수 있습니다.
|
- [완료] AddSubscriptionEventSection: info 박스 `tertiary`로, 아이콘도 동일 컬러.
|
||||||
|
- [완료] DetailEventSection: 초록 상수 제거 → `colorScheme.success`/onPrimary.
|
||||||
|
- [완료] SMS Scan 위젯: 로딩 인디케이터/버튼을 `primary` 기반으로.
|
||||||
|
- [완료] SubscriptionPieChartCard: AppColors 제거, 팔레트는 `primary/success/warning/error/tertiary/secondary` + 화이트 라벨. 환율 배지는 `primary` 소프트 톤.
|
||||||
|
- [완료] EventAnalysisCard: 현재가/할인율 배지 색을 `success/error`로 정리.
|
||||||
|
- [완료] TotalExpenseSummaryCard: 아이콘을 `success`로 정리.
|
||||||
|
- [완료] Splash: overlay/파티클/타이틀/서브타이틀/인디케이터를 ColorScheme 기반으로 단순화(파티클 색은 렌더 시 `primary`).
|
||||||
|
- [검증] `scripts/check.sh` 재실행 통과.
|
||||||
|
|
||||||
|
### 2025-09-11 Dark Theme 정리
|
||||||
|
- [완료] adaptive_theme.dart 다크 테마를 전면 ColorScheme 기반으로 재정렬:
|
||||||
|
- InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant.
|
||||||
|
- Elevated/Switch/Checkbox/Radio/Slider/TabBar/Divider: scheme 값 사용.
|
||||||
|
- AppBar/Card: 배경=surface, 전경/테두리=scheme on/outline.
|
||||||
|
- OLED 테마는 surface만 더 어둡게 덮어쓰기.
|
||||||
|
- [검증] `scripts/check.sh` 통과.
|
||||||
|
|
||||||
|
### 2025-09-11 Light Theme 추가 정리
|
||||||
|
- [완료] app_theme.dart 라이트 테마를 ColorScheme 기반으로 정리:
|
||||||
|
- InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant.
|
||||||
|
- Elevated/Text/Outlined/FAB: primary/onPrimary, Outlined 보더=outline.
|
||||||
|
- SnackBarTheme: primary/onPrimary.
|
||||||
|
- Scaffold 배경은 기존 디자인(#F1F5F9)을 유지(직접 지정).
|
||||||
|
- [검증] `scripts/check.sh` 재실행 통과.
|
||||||
|
|||||||
122
doc/payment_card_plan.md
Normal 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으로 카드 뷰를 안내.
|
||||||
|
- **데이터 입력 번거로움**: 관리 화면에서 최소 필드만 요구하고, 구독 폼에서 바로 생성할 수 있게 동선 축소.
|
||||||
70
doc/plan_color.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Color & Theme Plan (Material 3)
|
||||||
|
|
||||||
|
Goals
|
||||||
|
- Remove Glassmorphism. Use Material 3 ColorScheme/typography/shape/elevation consistently.
|
||||||
|
- Ensure light/dark/system modes with accessible contrast; no dark-on-dark text.
|
||||||
|
- Semantic colors: primary/secondary/info/success/warning/error.
|
||||||
|
|
||||||
|
Phases
|
||||||
|
1) Audit + Baseline
|
||||||
|
- Inventory AppColors and Glass usages; map to ColorScheme.
|
||||||
|
- Set `ColorScheme.error=#EF4444` (light/dark) and verify Snackbar uses.
|
||||||
|
|
||||||
|
2) Core Components
|
||||||
|
- Settings: unify to `onSurface/onSurfaceVariant/primary` and fix Switch deprecations.
|
||||||
|
- Home Summary: surface/elevation + on-colors; badges use surfaceContainer variants.
|
||||||
|
- Add Subscription: selectors/fields to M3; disable glass flags.
|
||||||
|
|
||||||
|
3) Analysis & Lists
|
||||||
|
- Charts: grid/labels to onSurfaceVariant; tooltips to inverseSurface.
|
||||||
|
- Event/Detail sections: containers to surface + outline; text/icons to onSurface.
|
||||||
|
|
||||||
|
4) Theme & Cleanup
|
||||||
|
- Refactor `app_theme.dart` to remove glass defaults; prefer ColorScheme-driven themes.
|
||||||
|
- Replace remaining hard-coded colors (AppColors.*) with scheme; keep gradients sparingly.
|
||||||
|
- Resolve lints: const constructors, deprecated APIs (activeColor, scale).
|
||||||
|
|
||||||
|
Validation
|
||||||
|
- Run `scripts/check.sh` every change.
|
||||||
|
- Visual check in light/dark/system; confirm no low-contrast text.
|
||||||
|
|
||||||
|
Current Status (2025-09-10)
|
||||||
|
- Settings screen updated to ColorScheme; Switch deprecations fixed.
|
||||||
|
- AddSubscription selectors use M3 (glass flags off).
|
||||||
|
- MainSummaryCard event-savings text = primary.
|
||||||
|
- Monthly chart tooltips use inverseSurface/onInverseSurface.
|
||||||
|
- Next: theme/app_theme.dart cleanup; remaining AppColors usages; chart palette alignment.
|
||||||
|
|
||||||
|
Current Status (2025-09-11)
|
||||||
|
- Billing/Currency/Category selectors: use ColorScheme (selected=primary/onPrimary, unselected=surface+outline, text=onSurface). Glass/grad props deprecated and ignored.
|
||||||
|
- AnalysisBadge: remove AppColors, use surface + outline + subtle shadow.
|
||||||
|
- SubscriptionCard: header strip uses error/warning/category; price and badges use ColorScheme (error/primary/onSurfaceVariant); due-chip uses success/warning extension; removed hard-coded reds/grays.
|
||||||
|
- Checks: scripts/check.sh passes (format/analyze/test).
|
||||||
|
- Next: migrate remaining AppColors usages (detail sections, snackbars, splash), reduce hard-coded Colors in adaptive_theme.dart, optional: revisit success/warning harmonization.
|
||||||
|
|
||||||
|
Update (2025-09-11, PM)
|
||||||
|
- DetailEventSection: replaced green constants with ColorScheme.success; onPrimary for pill text.
|
||||||
|
- AddSubscriptionEventSection: info boxes use tertiary; removed AppColors.
|
||||||
|
- SMS Scan widgets: progress/button now use ColorScheme.primary.
|
||||||
|
- SubscriptionPieChartCard: no AppColors; chart palette uses scheme.success/warning; in-chart labels are white; exchange-rate chip uses primary soft background/border.
|
||||||
|
- EventAnalysisCard: discount/current price and discount badge use scheme.success/error.
|
||||||
|
- TotalExpenseSummaryCard: success icon uses scheme.success.
|
||||||
|
- AppLock/Main screen SnackBars: unified to scheme.error/success with onPrimary text.
|
||||||
|
- Splash: overlay/particles/title/subtitle/progress use ColorScheme; particle color bound to scheme.primary.
|
||||||
|
- Checks: scripts/check.sh passes.
|
||||||
|
|
||||||
|
Update (2025-09-11, PM-2)
|
||||||
|
- Dark Theme(adaptive_theme.dart): replaced hard-coded widget colors with ColorScheme-driven values.
|
||||||
|
- Inputs: fill=surface, borders=outline/primary/error, labels/hints=onSurfaceVariant.
|
||||||
|
- Buttons/Switch/Checkbox/Radio/Slider/TabBar/Divider: all use scheme tokens.
|
||||||
|
- AppBar/Card: background=surface, foreground/on-colors from scheme.
|
||||||
|
- OLED: inherits dark with surface override only.
|
||||||
|
- Checks: scripts/check.sh passes (no issues).
|
||||||
|
|
||||||
|
Update (2025-09-11, PM-3)
|
||||||
|
- Light Theme(app_theme.dart): AppColors 의존을 ColorScheme 사용으로 축소.
|
||||||
|
- InputDecorationTheme: fill=surface, border=outline/primary/error, label/hint=onSurfaceVariant.
|
||||||
|
- Buttons/FAB: primary/onPrimary, Outlined side=outline.
|
||||||
|
- SnackBarTheme: primary/onPrimary.
|
||||||
|
- Scaffold background 유지(#F1F5F9) — ColorScheme.background 대신 직접 지정.
|
||||||
|
- Checks: scripts/check.sh passes.
|
||||||
43
doc/pr/codex-fix-notification-reliability.md
Normal 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.
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -7,7 +8,17 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 앱이 포그라운드일 때도 배너/사운드/배지 표시
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .sound, .badge])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
ios/Runner/en.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* Localized display name */
|
||||||
|
"CFBundleDisplayName" = "Digital Rent Manager";
|
||||||
|
|
||||||
3
ios/Runner/ja.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* ローカライズされたアプリ表示名 */
|
||||||
|
"CFBundleDisplayName" = "デジタル月額管理者";
|
||||||
|
|
||||||
3
ios/Runner/ko.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* 로컬라이즈된 앱 표시 이름 */
|
||||||
|
"CFBundleDisplayName" = "디지털 월세 관리자";
|
||||||
|
|
||||||
3
ios/Runner/zh.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* 本地化的应用显示名称 */
|
||||||
|
"CFBundleDisplayName" = "数字月租管理器";
|
||||||
|
|
||||||
@@ -4,12 +4,12 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import '../providers/category_provider.dart';
|
import '../providers/category_provider.dart';
|
||||||
|
import '../providers/payment_card_provider.dart';
|
||||||
import '../services/sms_service.dart';
|
import '../services/sms_service.dart';
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../services/subscription_url_matcher.dart';
|
||||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../utils/billing_date_util.dart';
|
import '../utils/billing_date_util.dart';
|
||||||
import '../utils/business_day_util.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
|
|
||||||
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
||||||
@@ -32,6 +32,7 @@ class AddSubscriptionController {
|
|||||||
DateTime? nextBillingDate;
|
DateTime? nextBillingDate;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
String? selectedCategoryId;
|
String? selectedCategoryId;
|
||||||
|
String? selectedPaymentCardId;
|
||||||
|
|
||||||
// Event State
|
// Event State
|
||||||
bool isEventActive = false;
|
bool isEventActive = false;
|
||||||
@@ -127,6 +128,13 @@ class AddSubscriptionController {
|
|||||||
// Localizations가 아직 준비되지 않은 경우 기본값 유지
|
// Localizations가 아직 준비되지 않은 경우 기본값 유지
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 기본 결제수단 설정
|
||||||
|
try {
|
||||||
|
final paymentCardProvider =
|
||||||
|
Provider.of<PaymentCardProvider>(context, listen: false);
|
||||||
|
selectedPaymentCardId = paymentCardProvider.defaultCard?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// 애니메이션 시작
|
// 애니메이션 시작
|
||||||
animationController!.forward();
|
animationController!.forward();
|
||||||
}
|
}
|
||||||
@@ -495,7 +503,6 @@ class AddSubscriptionController {
|
|||||||
);
|
);
|
||||||
var adjustedNext =
|
var adjustedNext =
|
||||||
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
|
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
|
||||||
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
|
|
||||||
|
|
||||||
await Provider.of<SubscriptionProvider>(context, listen: false)
|
await Provider.of<SubscriptionProvider>(context, listen: false)
|
||||||
.addSubscription(
|
.addSubscription(
|
||||||
@@ -505,6 +512,7 @@ class AddSubscriptionController {
|
|||||||
nextBillingDate: adjustedNext,
|
nextBillingDate: adjustedNext,
|
||||||
websiteUrl: websiteUrlController.text.trim(),
|
websiteUrl: websiteUrlController.text.trim(),
|
||||||
categoryId: selectedCategoryId,
|
categoryId: selectedCategoryId,
|
||||||
|
paymentCardId: selectedPaymentCardId,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
isEventActive: isEventActive,
|
isEventActive: isEventActive,
|
||||||
eventStartDate: eventStartDate,
|
eventStartDate: eventStartDate,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:intl/intl.dart';
|
|||||||
import '../widgets/dialogs/delete_confirmation_dialog.dart';
|
import '../widgets/dialogs/delete_confirmation_dialog.dart';
|
||||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/billing_date_util.dart';
|
||||||
|
|
||||||
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
||||||
class DetailScreenController extends ChangeNotifier {
|
class DetailScreenController extends ChangeNotifier {
|
||||||
@@ -33,6 +34,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
late String _billingCycle;
|
late String _billingCycle;
|
||||||
late DateTime _nextBillingDate;
|
late DateTime _nextBillingDate;
|
||||||
String? _selectedCategoryId;
|
String? _selectedCategoryId;
|
||||||
|
String? _selectedPaymentCardId;
|
||||||
late String _currency;
|
late String _currency;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
String get billingCycle => _billingCycle;
|
String get billingCycle => _billingCycle;
|
||||||
DateTime get nextBillingDate => _nextBillingDate;
|
DateTime get nextBillingDate => _nextBillingDate;
|
||||||
String? get selectedCategoryId => _selectedCategoryId;
|
String? get selectedCategoryId => _selectedCategoryId;
|
||||||
|
String? get selectedPaymentCardId => _selectedPaymentCardId;
|
||||||
String get currency => _currency;
|
String get currency => _currency;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
bool get isEventActive => _isEventActive;
|
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) {
|
set currency(String value) {
|
||||||
if (_currency != value) {
|
if (_currency != value) {
|
||||||
_currency = value;
|
_currency = value;
|
||||||
@@ -152,6 +162,7 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
_billingCycle = subscription.billingCycle;
|
_billingCycle = subscription.billingCycle;
|
||||||
_nextBillingDate = subscription.nextBillingDate;
|
_nextBillingDate = subscription.nextBillingDate;
|
||||||
_selectedCategoryId = subscription.categoryId;
|
_selectedCategoryId = subscription.categoryId;
|
||||||
|
_selectedPaymentCardId = subscription.paymentCardId;
|
||||||
_currency = subscription.currency;
|
_currency = subscription.currency;
|
||||||
|
|
||||||
// Event State 초기화
|
// Event State 초기화
|
||||||
@@ -407,8 +418,14 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
subscription.monthlyCost = monthlyCost;
|
subscription.monthlyCost = monthlyCost;
|
||||||
subscription.websiteUrl = websiteUrl;
|
subscription.websiteUrl = websiteUrl;
|
||||||
subscription.billingCycle = _billingCycle;
|
subscription.billingCycle = _billingCycle;
|
||||||
subscription.nextBillingDate = _nextBillingDate;
|
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장
|
||||||
|
final originalDateOnly = DateTime(
|
||||||
|
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
|
||||||
|
var adjustedNext =
|
||||||
|
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
|
||||||
|
subscription.nextBillingDate = adjustedNext;
|
||||||
subscription.categoryId = _selectedCategoryId;
|
subscription.categoryId = _selectedCategoryId;
|
||||||
|
subscription.paymentCardId = _selectedPaymentCardId;
|
||||||
subscription.currency = _currency;
|
subscription.currency = _currency;
|
||||||
|
|
||||||
// 이벤트 정보 업데이트
|
// 이벤트 정보 업데이트
|
||||||
@@ -433,6 +450,14 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
'이벤트활성=${subscription.isEventActive}');
|
'이벤트활성=${subscription.isEventActive}');
|
||||||
|
|
||||||
// 구독 업데이트
|
// 구독 업데이트
|
||||||
|
// 자동 보정이 발생했으면 안내
|
||||||
|
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||||
|
AppSnackBar.showInfo(
|
||||||
|
context: context,
|
||||||
|
message: '다음 결제 예정일로 저장됨',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await provider.updateSubscription(subscription);
|
await provider.updateSubscription(subscription);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -575,15 +600,5 @@ class DetailScreenController extends ChangeNotifier {
|
|||||||
return colors[hash % colors.length];
|
return colors[hash % colors.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 그라데이션 가져오기
|
// getGradient 제거됨 (그라데이션 미사용)
|
||||||
LinearGradient getGradient(Color baseColor) {
|
|
||||||
return LinearGradient(
|
|
||||||
colors: [
|
|
||||||
baseColor,
|
|
||||||
baseColor.withValues(alpha: 0.8),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/sms_scanner.dart';
|
import '../services/sms_scanner.dart';
|
||||||
import '../models/subscription.dart';
|
import '../models/subscription.dart';
|
||||||
|
import '../models/payment_card_suggestion.dart';
|
||||||
import '../services/sms_scan/subscription_converter.dart';
|
import '../services/sms_scan/subscription_converter.dart';
|
||||||
import '../services/sms_scan/subscription_filter.dart';
|
import '../services/sms_scan/subscription_filter.dart';
|
||||||
|
import '../services/sms_scan/sms_scan_result.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
@@ -11,6 +13,7 @@ import '../utils/logger.dart';
|
|||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../providers/category_provider.dart';
|
import '../providers/category_provider.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/payment_card_provider.dart';
|
||||||
|
|
||||||
class SmsScanController extends ChangeNotifier {
|
class SmsScanController extends ChangeNotifier {
|
||||||
// 상태 관리
|
// 상태 관리
|
||||||
@@ -22,22 +25,32 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
|
|
||||||
List<Subscription> _scannedSubscriptions = [];
|
List<Subscription> _scannedSubscriptions = [];
|
||||||
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
|
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
|
||||||
|
PaymentCardSuggestion? _currentSuggestion;
|
||||||
|
PaymentCardSuggestion? get currentSuggestion => _currentSuggestion;
|
||||||
|
bool _shouldSuggestCardCreation = false;
|
||||||
|
bool get shouldSuggestCardCreation => _shouldSuggestCardCreation;
|
||||||
|
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
int get currentIndex => _currentIndex;
|
int get currentIndex => _currentIndex;
|
||||||
|
|
||||||
String? _selectedCategoryId;
|
String? _selectedCategoryId;
|
||||||
String? get selectedCategoryId => _selectedCategoryId;
|
String? get selectedCategoryId => _selectedCategoryId;
|
||||||
|
String? _selectedPaymentCardId;
|
||||||
|
String? get selectedPaymentCardId => _selectedPaymentCardId;
|
||||||
|
|
||||||
final TextEditingController websiteUrlController = TextEditingController();
|
final TextEditingController websiteUrlController = TextEditingController();
|
||||||
|
final TextEditingController serviceNameController = TextEditingController();
|
||||||
|
|
||||||
// 의존성
|
// 의존성
|
||||||
final SmsScanner _smsScanner = SmsScanner();
|
final SmsScanner _smsScanner = SmsScanner();
|
||||||
final SubscriptionConverter _converter = SubscriptionConverter();
|
final SubscriptionConverter _converter = SubscriptionConverter();
|
||||||
final SubscriptionFilter _filter = SubscriptionFilter();
|
final SubscriptionFilter _filter = SubscriptionFilter();
|
||||||
|
bool _forceServiceNameEditing = false;
|
||||||
|
bool get isServiceNameEditable => _forceServiceNameEditing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
serviceNameController.dispose();
|
||||||
websiteUrlController.dispose();
|
websiteUrlController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -47,8 +60,26 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSelectedPaymentCardId(String? paymentCardId) {
|
||||||
|
_selectedPaymentCardId = paymentCardId;
|
||||||
|
if (paymentCardId != null) {
|
||||||
|
_shouldSuggestCardCreation = false;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void resetWebsiteUrl() {
|
void resetWebsiteUrl() {
|
||||||
websiteUrlController.text = '';
|
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 {
|
Future<void> scanSms(BuildContext context) async {
|
||||||
@@ -88,18 +119,18 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
|
|
||||||
// SMS 스캔 실행
|
// SMS 스캔 실행
|
||||||
Log.i('SMS 스캔 시작');
|
Log.i('SMS 스캔 시작');
|
||||||
final scannedSubscriptionModels =
|
final List<SmsScanResult> scanResults =
|
||||||
await _smsScanner.scanForSubscriptions();
|
await _smsScanner.scanForSubscriptions();
|
||||||
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
Log.d('스캔된 구독: ${scanResults.length}개');
|
||||||
|
|
||||||
if (scannedSubscriptionModels.isNotEmpty) {
|
if (scanResults.isNotEmpty) {
|
||||||
Log.d(
|
Log.d(
|
||||||
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
'첫 번째 구독: ${scanResults[0].model.serviceName}, 반복 횟수: ${scanResults[0].model.repeatCount}');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
if (scannedSubscriptionModels.isEmpty) {
|
if (scanResults.isEmpty) {
|
||||||
Log.i('스캔된 구독이 없음');
|
Log.i('스캔된 구독이 없음');
|
||||||
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
|
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -109,7 +140,7 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
|
|
||||||
// SubscriptionModel을 Subscription으로 변환
|
// SubscriptionModel을 Subscription으로 변환
|
||||||
final scannedSubscriptions =
|
final scannedSubscriptions =
|
||||||
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
|
_converter.convertResultsToSubscriptions(scanResults);
|
||||||
|
|
||||||
// 2회 이상 반복 결제된 구독만 필터링
|
// 2회 이상 반복 결제된 구독만 필터링
|
||||||
final repeatSubscriptions =
|
final repeatSubscriptions =
|
||||||
@@ -155,7 +186,9 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
|
|
||||||
_scannedSubscriptions = filteredSubscriptions;
|
_scannedSubscriptions = filteredSubscriptions;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
websiteUrlController.text = ''; // URL 입력 필드 초기화
|
websiteUrlController.text = '';
|
||||||
|
_currentSuggestion = null;
|
||||||
|
_prepareCurrentSelection(context);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.e('SMS 스캔 중 오류 발생', e);
|
Log.e('SMS 스캔 중 오류 발생', e);
|
||||||
@@ -196,16 +229,23 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
if (_currentIndex >= _scannedSubscriptions.length) return;
|
if (_currentIndex >= _scannedSubscriptions.length) return;
|
||||||
|
|
||||||
final subscription = _scannedSubscriptions[_currentIndex];
|
final subscription = _scannedSubscriptions[_currentIndex];
|
||||||
|
final inputName = serviceNameController.text.trim();
|
||||||
|
final resolvedServiceName =
|
||||||
|
inputName.isNotEmpty ? inputName : subscription.serviceName;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final provider =
|
final provider =
|
||||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||||
final categoryProvider =
|
final categoryProvider =
|
||||||
Provider.of<CategoryProvider>(context, listen: false);
|
Provider.of<CategoryProvider>(context, listen: false);
|
||||||
|
final paymentCardProvider =
|
||||||
|
Provider.of<PaymentCardProvider>(context, listen: false);
|
||||||
|
|
||||||
final finalCategoryId = _selectedCategoryId ??
|
final finalCategoryId = _selectedCategoryId ??
|
||||||
subscription.category ??
|
subscription.category ??
|
||||||
getDefaultCategoryId(categoryProvider);
|
getDefaultCategoryId(categoryProvider);
|
||||||
|
final finalPaymentCardId =
|
||||||
|
_selectedPaymentCardId ?? paymentCardProvider.defaultCard?.id;
|
||||||
|
|
||||||
// websiteUrl 처리
|
// websiteUrl 처리
|
||||||
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
|
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
|
||||||
@@ -217,7 +257,7 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
|
|
||||||
// addSubscription 호출
|
// addSubscription 호출
|
||||||
await provider.addSubscription(
|
await provider.addSubscription(
|
||||||
serviceName: subscription.serviceName,
|
serviceName: resolvedServiceName,
|
||||||
monthlyCost: subscription.monthlyCost,
|
monthlyCost: subscription.monthlyCost,
|
||||||
billingCycle: subscription.billingCycle,
|
billingCycle: subscription.billingCycle,
|
||||||
nextBillingDate: subscription.nextBillingDate,
|
nextBillingDate: subscription.nextBillingDate,
|
||||||
@@ -226,6 +266,7 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
repeatCount: subscription.repeatCount,
|
repeatCount: subscription.repeatCount,
|
||||||
lastPaymentDate: subscription.lastPaymentDate,
|
lastPaymentDate: subscription.lastPaymentDate,
|
||||||
categoryId: finalCategoryId,
|
categoryId: finalCategoryId,
|
||||||
|
paymentCardId: finalPaymentCardId,
|
||||||
currency: subscription.currency,
|
currency: subscription.currency,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -248,8 +289,11 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
|
|
||||||
void moveToNextSubscription(BuildContext context) {
|
void moveToNextSubscription(BuildContext context) {
|
||||||
_currentIndex++;
|
_currentIndex++;
|
||||||
websiteUrlController.text = ''; // URL 입력 필드 초기화
|
websiteUrlController.text = '';
|
||||||
_selectedCategoryId = null; // 카테고리 선택 초기화
|
serviceNameController.text = '';
|
||||||
|
_selectedCategoryId = null;
|
||||||
|
_forceServiceNameEditing = false;
|
||||||
|
_prepareCurrentSelection(context);
|
||||||
|
|
||||||
// 모든 구독을 처리했으면 홈 화면으로 이동
|
// 모든 구독을 처리했으면 홈 화면으로 이동
|
||||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||||
@@ -270,6 +314,11 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
_scannedSubscriptions = [];
|
_scannedSubscriptions = [];
|
||||||
_currentIndex = 0;
|
_currentIndex = 0;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_selectedPaymentCardId = null;
|
||||||
|
_currentSuggestion = null;
|
||||||
|
_shouldSuggestCardCreation = false;
|
||||||
|
serviceNameController.clear();
|
||||||
|
_forceServiceNameEditing = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +337,100 @@ class SmsScanController extends ChangeNotifier {
|
|||||||
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
||||||
websiteUrlController.text = currentSub.websiteUrl!;
|
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 == '알 수 없는 서비스';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,56 @@ class AppLocalizations {
|
|||||||
String get notifications =>
|
String get notifications =>
|
||||||
_localizedStrings['notifications'] ?? 'Notifications';
|
_localizedStrings['notifications'] ?? 'Notifications';
|
||||||
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
|
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 권한 온보딩/설정
|
// SMS 권한 온보딩/설정
|
||||||
String get smsPermissionTitle =>
|
String get smsPermissionTitle =>
|
||||||
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
|
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
|
||||||
@@ -116,6 +166,7 @@ class AppLocalizations {
|
|||||||
// 앱 정보
|
// 앱 정보
|
||||||
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
||||||
String get version => _localizedStrings['version'] ?? 'Version';
|
String get version => _localizedStrings['version'] ?? 'Version';
|
||||||
|
String get openStore => _localizedStrings['openStore'] ?? 'Open Store';
|
||||||
String get appDescription =>
|
String get appDescription =>
|
||||||
_localizedStrings['appDescription'] ?? 'Subscription Management App';
|
_localizedStrings['appDescription'] ?? 'Subscription Management App';
|
||||||
String get developer => _localizedStrings['developer'] ?? 'Developer';
|
String get developer => _localizedStrings['developer'] ?? 'Developer';
|
||||||
@@ -367,6 +418,9 @@ class AppLocalizations {
|
|||||||
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
|
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
|
||||||
String get eventDiscountStatus =>
|
String get eventDiscountStatus =>
|
||||||
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
|
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
|
||||||
|
String get eventDiscountEndsBeforeBilling =>
|
||||||
|
_localizedStrings['eventDiscountEndsBeforeBilling'] ??
|
||||||
|
'Event discount ends before billing date';
|
||||||
String get inProgressUnit =>
|
String get inProgressUnit =>
|
||||||
_localizedStrings['inProgressUnit'] ?? 'in progress';
|
_localizedStrings['inProgressUnit'] ?? 'in progress';
|
||||||
String get monthlySavingAmount =>
|
String get monthlySavingAmount =>
|
||||||
@@ -403,6 +457,13 @@ class AppLocalizations {
|
|||||||
String get foundSubscription =>
|
String get foundSubscription =>
|
||||||
_localizedStrings['foundSubscription'] ?? 'Found subscription';
|
_localizedStrings['foundSubscription'] ?? 'Found subscription';
|
||||||
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
|
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 =>
|
String get nextBillingDateLabel =>
|
||||||
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
|
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
|
||||||
String get category => _localizedStrings['category'] ?? 'Category';
|
String get category => _localizedStrings['category'] ?? 'Category';
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'models/subscription_model.dart';
|
import 'models/subscription_model.dart';
|
||||||
import 'models/category_model.dart';
|
import 'models/category_model.dart';
|
||||||
|
import 'models/payment_card_model.dart';
|
||||||
import 'providers/subscription_provider.dart';
|
import 'providers/subscription_provider.dart';
|
||||||
import 'providers/app_lock_provider.dart';
|
import 'providers/app_lock_provider.dart';
|
||||||
import 'providers/notification_provider.dart';
|
import 'providers/notification_provider.dart';
|
||||||
import 'providers/navigation_provider.dart';
|
import 'providers/navigation_provider.dart';
|
||||||
|
import 'providers/payment_card_provider.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'providers/category_provider.dart';
|
import 'providers/category_provider.dart';
|
||||||
import 'providers/locale_provider.dart';
|
import 'providers/locale_provider.dart';
|
||||||
@@ -69,14 +71,17 @@ Future<void> main() async {
|
|||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
Hive.registerAdapter(SubscriptionModelAdapter());
|
Hive.registerAdapter(SubscriptionModelAdapter());
|
||||||
Hive.registerAdapter(CategoryModelAdapter());
|
Hive.registerAdapter(CategoryModelAdapter());
|
||||||
|
Hive.registerAdapter(PaymentCardModelAdapter());
|
||||||
await Hive.openBox<SubscriptionModel>('subscriptions');
|
await Hive.openBox<SubscriptionModel>('subscriptions');
|
||||||
await Hive.openBox<CategoryModel>('categories');
|
await Hive.openBox<CategoryModel>('categories');
|
||||||
|
await Hive.openBox<PaymentCardModel>('payment_cards');
|
||||||
final appLockBox = await Hive.openBox<bool>('app_lock');
|
final appLockBox = await Hive.openBox<bool>('app_lock');
|
||||||
// 알림 서비스를 가장 먼저 초기화
|
// 알림 서비스를 가장 먼저 초기화
|
||||||
await NotificationService.init();
|
await NotificationService.init();
|
||||||
|
|
||||||
final subscriptionProvider = SubscriptionProvider();
|
final subscriptionProvider = SubscriptionProvider();
|
||||||
final categoryProvider = CategoryProvider();
|
final categoryProvider = CategoryProvider();
|
||||||
|
final paymentCardProvider = PaymentCardProvider();
|
||||||
final localeProvider = LocaleProvider();
|
final localeProvider = LocaleProvider();
|
||||||
final notificationProvider = NotificationProvider();
|
final notificationProvider = NotificationProvider();
|
||||||
final themeProvider = ThemeProvider();
|
final themeProvider = ThemeProvider();
|
||||||
@@ -84,6 +89,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
await subscriptionProvider.init();
|
await subscriptionProvider.init();
|
||||||
await categoryProvider.init();
|
await categoryProvider.init();
|
||||||
|
await paymentCardProvider.init();
|
||||||
await localeProvider.init();
|
await localeProvider.init();
|
||||||
await notificationProvider.init();
|
await notificationProvider.init();
|
||||||
await themeProvider.initialize();
|
await themeProvider.initialize();
|
||||||
@@ -110,6 +116,7 @@ Future<void> main() async {
|
|||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => subscriptionProvider),
|
ChangeNotifierProvider(create: (_) => subscriptionProvider),
|
||||||
ChangeNotifierProvider(create: (_) => categoryProvider),
|
ChangeNotifierProvider(create: (_) => categoryProvider),
|
||||||
|
ChangeNotifierProvider(create: (_) => paymentCardProvider),
|
||||||
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
|
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
|
||||||
ChangeNotifierProvider(create: (_) => notificationProvider),
|
ChangeNotifierProvider(create: (_) => notificationProvider),
|
||||||
ChangeNotifierProvider(create: (_) => localeProvider),
|
ChangeNotifierProvider(create: (_) => localeProvider),
|
||||||
@@ -133,7 +140,9 @@ class SubManagerApp extends StatelessWidget {
|
|||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
key: ValueKey(localeProvider.locale),
|
key: ValueKey(localeProvider.locale),
|
||||||
title: 'Digital Rent Manager',
|
// Localizations는 MaterialApp 내부에서 초기화되므로
|
||||||
|
// onGenerateTitle을 사용해 로딩 이후 로컬라이즈된 타이틀을 설정합니다.
|
||||||
|
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: themeProvider.getTheme(context),
|
theme: themeProvider.getTheme(context),
|
||||||
locale: localeProvider.locale,
|
locale: localeProvider.locale,
|
||||||
|
|||||||
33
lib/models/payment_card_model.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
56
lib/models/payment_card_model.g.dart
Normal 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;
|
||||||
|
}
|
||||||
14
lib/models/payment_card_suggestion.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'payment_card_suggestion.dart';
|
||||||
|
|
||||||
class Subscription {
|
class Subscription {
|
||||||
final String id;
|
final String id;
|
||||||
final String serviceName;
|
final String serviceName;
|
||||||
@@ -10,6 +12,9 @@ class Subscription {
|
|||||||
final DateTime? lastPaymentDate;
|
final DateTime? lastPaymentDate;
|
||||||
final String? websiteUrl;
|
final String? websiteUrl;
|
||||||
final String currency;
|
final String currency;
|
||||||
|
final String? paymentCardId;
|
||||||
|
final PaymentCardSuggestion? paymentCardSuggestion;
|
||||||
|
final String? rawMessage;
|
||||||
|
|
||||||
Subscription({
|
Subscription({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -23,8 +28,52 @@ class Subscription {
|
|||||||
this.lastPaymentDate,
|
this.lastPaymentDate,
|
||||||
this.websiteUrl,
|
this.websiteUrl,
|
||||||
this.currency = 'KRW',
|
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() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
@@ -38,6 +87,11 @@ class Subscription {
|
|||||||
'lastPaymentDate': lastPaymentDate?.toIso8601String(),
|
'lastPaymentDate': lastPaymentDate?.toIso8601String(),
|
||||||
'websiteUrl': websiteUrl,
|
'websiteUrl': websiteUrl,
|
||||||
'currency': currency,
|
'currency': currency,
|
||||||
|
'paymentCardId': paymentCardId,
|
||||||
|
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
|
||||||
|
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
|
||||||
|
'paymentCardSuggestionSource': paymentCardSuggestion?.source,
|
||||||
|
'rawMessage': rawMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +110,15 @@ class Subscription {
|
|||||||
: null,
|
: null,
|
||||||
websiteUrl: map['websiteUrl'] as String?,
|
websiteUrl: map['websiteUrl'] as String?,
|
||||||
currency: map['currency'] as String? ?? 'KRW',
|
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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class SubscriptionModel extends HiveObject {
|
|||||||
@HiveField(14)
|
@HiveField(14)
|
||||||
double? eventPrice; // 이벤트 기간 중 가격
|
double? eventPrice; // 이벤트 기간 중 가격
|
||||||
|
|
||||||
|
@HiveField(15)
|
||||||
|
String? paymentCardId; // 연결된 결제수단의 ID
|
||||||
|
|
||||||
SubscriptionModel({
|
SubscriptionModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.serviceName,
|
required this.serviceName,
|
||||||
@@ -65,6 +68,7 @@ class SubscriptionModel extends HiveObject {
|
|||||||
this.eventStartDate,
|
this.eventStartDate,
|
||||||
this.eventEndDate,
|
this.eventEndDate,
|
||||||
this.eventPrice,
|
this.eventPrice,
|
||||||
|
this.paymentCardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 주기적 결제 여부 확인
|
// 주기적 결제 여부 확인
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
|
|||||||
eventStartDate: fields[12] as DateTime?,
|
eventStartDate: fields[12] as DateTime?,
|
||||||
eventEndDate: fields[13] as DateTime?,
|
eventEndDate: fields[13] as DateTime?,
|
||||||
eventPrice: fields[14] as double?,
|
eventPrice: fields[14] as double?,
|
||||||
|
paymentCardId: fields[15] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, SubscriptionModel obj) {
|
void write(BinaryWriter writer, SubscriptionModel obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(15)
|
..writeByte(16)
|
||||||
..writeByte(0)
|
..writeByte(0)
|
||||||
..write(obj.id)
|
..write(obj.id)
|
||||||
..writeByte(1)
|
..writeByte(1)
|
||||||
@@ -68,7 +69,9 @@ class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
|
|||||||
..writeByte(13)
|
..writeByte(13)
|
||||||
..write(obj.eventEndDate)
|
..write(obj.eventEndDate)
|
||||||
..writeByte(14)
|
..writeByte(14)
|
||||||
..write(obj.eventPrice);
|
..write(obj.eventPrice)
|
||||||
|
..writeByte(15)
|
||||||
|
..write(obj.paymentCardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
|
|
||||||
class AppNavigationObserver extends NavigatorObserver {
|
class AppNavigationObserver extends NavigatorObserver {
|
||||||
@override
|
@override
|
||||||
@@ -47,6 +48,12 @@ class AppNavigationObserver extends NavigatorObserver {
|
|||||||
final routeName = route.settings.name;
|
final routeName = route.settings.name;
|
||||||
if (routeName == null) return;
|
if (routeName == null) return;
|
||||||
|
|
||||||
|
// 메인 화면('/')은 하단 탭으로 상태를 관리하므로
|
||||||
|
// 모달 닫힘 등으로 인해 홈 탭으로 강제 전환하지 않도록 무시한다.
|
||||||
|
if (routeName == AppRoutes.main || routeName == '/') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// build 완료 후 업데이트하도록 변경
|
// build 완료 후 업데이트하도록 변경
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (navigator?.context == null) return;
|
if (navigator?.context == null) return;
|
||||||
|
|||||||
@@ -114,6 +114,23 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
|
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
|
||||||
if (value) {
|
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 응답성 향상
|
// 지연 실행으로 UI 응답성 향상
|
||||||
Future.microtask(() => _rescheduleNotificationsIfNeeded());
|
Future.microtask(() => _rescheduleNotificationsIfNeeded());
|
||||||
|
|||||||
124
lib/providers/payment_card_provider.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,14 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _reschedulePaymentNotifications() async {
|
||||||
|
try {
|
||||||
|
await NotificationService.reschedulAllNotifications(_subscriptions);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('결제 알림 재예약 중 오류 발생: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addSubscription({
|
Future<void> addSubscription({
|
||||||
required String serviceName,
|
required String serviceName,
|
||||||
required double monthlyCost,
|
required double monthlyCost,
|
||||||
@@ -110,6 +118,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
required DateTime nextBillingDate,
|
required DateTime nextBillingDate,
|
||||||
String? websiteUrl,
|
String? websiteUrl,
|
||||||
String? categoryId,
|
String? categoryId,
|
||||||
|
String? paymentCardId,
|
||||||
bool isAutoDetected = false,
|
bool isAutoDetected = false,
|
||||||
int repeatCount = 1,
|
int repeatCount = 1,
|
||||||
DateTime? lastPaymentDate,
|
DateTime? lastPaymentDate,
|
||||||
@@ -128,6 +137,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
nextBillingDate: nextBillingDate,
|
nextBillingDate: nextBillingDate,
|
||||||
websiteUrl: websiteUrl,
|
websiteUrl: websiteUrl,
|
||||||
categoryId: categoryId,
|
categoryId: categoryId,
|
||||||
|
paymentCardId: paymentCardId,
|
||||||
isAutoDetected: isAutoDetected,
|
isAutoDetected: isAutoDetected,
|
||||||
repeatCount: repeatCount,
|
repeatCount: repeatCount,
|
||||||
lastPaymentDate: lastPaymentDate,
|
lastPaymentDate: lastPaymentDate,
|
||||||
@@ -145,6 +155,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
if (isEventActive && eventEndDate != null) {
|
if (isEventActive && eventEndDate != null) {
|
||||||
await _scheduleEventEndNotification(subscription);
|
await _scheduleEventEndNotification(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _reschedulePaymentNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('구독 추가 중 오류 발생: $e');
|
debugPrint('구독 추가 중 오류 발생: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -176,6 +188,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
|
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
|
||||||
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
|
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
await _reschedulePaymentNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('구독 업데이트 중 오류 발생: $e');
|
debugPrint('구독 업데이트 중 오류 발생: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -186,6 +200,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
await _subscriptionBox.delete(id);
|
await _subscriptionBox.delete(id);
|
||||||
await refreshSubscriptions();
|
await refreshSubscriptions();
|
||||||
|
|
||||||
|
await _reschedulePaymentNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('구독 삭제 중 오류 발생: $e');
|
debugPrint('구독 삭제 중 오류 발생: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -213,6 +229,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
await _reschedulePaymentNotifications();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +244,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
title: '이벤트 종료 알림',
|
title: '이벤트 종료 알림',
|
||||||
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
|
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
|
||||||
scheduledDate: subscription.eventEndDate!,
|
scheduledDate: subscription.eventEndDate!,
|
||||||
|
channelId: NotificationService.expirationChannelId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,17 +270,22 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
|
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
|
||||||
Future<double> calculateTotalExpense({String? locale}) async {
|
Future<double> calculateTotalExpense({
|
||||||
if (_subscriptions.isEmpty) return 0.0;
|
String? locale,
|
||||||
|
List<SubscriptionModel>? subset,
|
||||||
|
}) async {
|
||||||
|
final targetSubscriptions = subset ?? _subscriptions;
|
||||||
|
if (targetSubscriptions.isEmpty) return 0.0;
|
||||||
|
|
||||||
// locale이 제공되지 않으면 현재 로케일 사용
|
// locale이 제공되지 않으면 현재 로케일 사용
|
||||||
final targetCurrency =
|
final targetCurrency =
|
||||||
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
|
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
|
||||||
|
|
||||||
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
|
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
|
||||||
|
'대상 구독: ${targetSubscriptions.length}개');
|
||||||
double total = 0.0;
|
double total = 0.0;
|
||||||
|
|
||||||
for (final subscription in _subscriptions) {
|
for (final subscription in targetSubscriptions) {
|
||||||
final currentPrice = subscription.currentPrice;
|
final currentPrice = subscription.currentPrice;
|
||||||
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
|
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
|
||||||
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||||
@@ -275,15 +299,19 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
total += converted ?? currentPrice;
|
total += converted ?? currentPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
|
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
|
||||||
|
'$targetCurrency (대상 ${targetSubscriptions.length}개)');
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
|
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
|
||||||
Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
|
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({
|
||||||
{String? locale}) async {
|
String? locale,
|
||||||
|
List<SubscriptionModel>? subset,
|
||||||
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final List<Map<String, dynamic>> monthlyData = [];
|
final List<Map<String, dynamic>> monthlyData = [];
|
||||||
|
final targetSubscriptions = subset ?? _subscriptions;
|
||||||
|
|
||||||
// locale이 제공되지 않으면 현재 로케일 사용
|
// locale이 제공되지 않으면 현재 로케일 사용
|
||||||
final targetCurrency =
|
final targetCurrency =
|
||||||
@@ -304,7 +332,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 해당 월에 활성화된 구독 계산
|
// 해당 월에 활성화된 구독 계산
|
||||||
for (final subscription in _subscriptions) {
|
for (final subscription in targetSubscriptions) {
|
||||||
if (isCurrentMonth) {
|
if (isCurrentMonth) {
|
||||||
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
|
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
|
||||||
final cost = subscription.currentPrice;
|
final cost = subscription.currentPrice;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:submanager/screens/settings_screen.dart';
|
|||||||
import 'package:submanager/screens/splash_screen.dart';
|
import 'package:submanager/screens/splash_screen.dart';
|
||||||
import 'package:submanager/screens/sms_permission_screen.dart';
|
import 'package:submanager/screens/sms_permission_screen.dart';
|
||||||
import 'package:submanager/models/subscription_model.dart';
|
import 'package:submanager/models/subscription_model.dart';
|
||||||
|
import 'package:submanager/screens/payment_card_management_screen.dart';
|
||||||
|
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
static const String splash = '/splash';
|
static const String splash = '/splash';
|
||||||
@@ -18,6 +19,7 @@ class AppRoutes {
|
|||||||
static const String analysis = '/analysis';
|
static const String analysis = '/analysis';
|
||||||
static const String settings = '/settings';
|
static const String settings = '/settings';
|
||||||
static const String smsPermission = '/sms-permission';
|
static const String smsPermission = '/sms-permission';
|
||||||
|
static const String paymentCardManagement = '/payment-card-management';
|
||||||
|
|
||||||
static Map<String, WidgetBuilder> getRoutes() {
|
static Map<String, WidgetBuilder> getRoutes() {
|
||||||
return {
|
return {
|
||||||
@@ -28,6 +30,7 @@ class AppRoutes {
|
|||||||
analysis: (context) => const AnalysisScreen(),
|
analysis: (context) => const AnalysisScreen(),
|
||||||
settings: (context) => const SettingsScreen(),
|
settings: (context) => const SettingsScreen(),
|
||||||
smsPermission: (context) => const SmsPermissionScreen(),
|
smsPermission: (context) => const SmsPermissionScreen(),
|
||||||
|
paymentCardManagement: (context) => const PaymentCardManagementScreen(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +64,8 @@ class AppRoutes {
|
|||||||
|
|
||||||
case smsPermission:
|
case smsPermission:
|
||||||
return _buildRoute(const SmsPermissionScreen(), routeSettings);
|
return _buildRoute(const SmsPermissionScreen(), routeSettings);
|
||||||
|
case paymentCardManagement:
|
||||||
|
return _buildRoute(const PaymentCardManagementScreen(), routeSettings);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return _errorRoute();
|
return _errorRoute();
|
||||||
|
|||||||
@@ -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_form.dart';
|
||||||
import '../widgets/add_subscription/add_subscription_event_section.dart';
|
import '../widgets/add_subscription/add_subscription_event_section.dart';
|
||||||
import '../widgets/add_subscription/add_subscription_save_button.dart';
|
import '../widgets/add_subscription/add_subscription_save_button.dart';
|
||||||
import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 새로운 구독을 추가하는 화면
|
/// 새로운 구독을 추가하는 화면
|
||||||
class AddSubscriptionScreen extends StatefulWidget {
|
class AddSubscriptionScreen extends StatefulWidget {
|
||||||
const AddSubscriptionScreen({Key? key}) : super(key: key);
|
const AddSubscriptionScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState();
|
State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState();
|
||||||
@@ -45,7 +45,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
_controller.scrollController.addListener(_onScroll);
|
_controller.scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.backgroundColor,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
appBar: AddSubscriptionAppBar(
|
appBar: AddSubscriptionAppBar(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.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/subscription_provider.dart';
|
||||||
import '../providers/locale_provider.dart';
|
import '../providers/locale_provider.dart';
|
||||||
|
import '../utils/payment_card_utils.dart';
|
||||||
import '../widgets/native_ad_widget.dart';
|
import '../widgets/native_ad_widget.dart';
|
||||||
import '../widgets/analysis/analysis_screen_spacer.dart';
|
import '../widgets/analysis/analysis_screen_spacer.dart';
|
||||||
import '../widgets/analysis/subscription_pie_chart_card.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/monthly_expense_chart_card.dart';
|
||||||
import '../widgets/analysis/event_analysis_card.dart';
|
import '../widgets/analysis/event_analysis_card.dart';
|
||||||
|
|
||||||
|
enum AnalysisCardFilterType { all, unassigned, card }
|
||||||
|
|
||||||
class AnalysisScreen extends StatefulWidget {
|
class AnalysisScreen extends StatefulWidget {
|
||||||
const AnalysisScreen({super.key});
|
const AnalysisScreen({super.key});
|
||||||
|
|
||||||
@@ -25,6 +32,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
List<Map<String, dynamic>> _monthlyData = [];
|
List<Map<String, dynamic>> _monthlyData = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String _lastDataHash = '';
|
String _lastDataHash = '';
|
||||||
|
AnalysisCardFilterType _filterType = AnalysisCardFilterType.all;
|
||||||
|
String? _selectedCardId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -42,7 +51,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// Provider 변경 감지
|
// Provider 변경 감지
|
||||||
final provider = Provider.of<SubscriptionProvider>(context);
|
final provider = Provider.of<SubscriptionProvider>(context);
|
||||||
final currentHash = _calculateDataHash(provider);
|
final filtered = _filterSubscriptions(provider.subscriptions);
|
||||||
|
final currentHash = _calculateDataHash(provider, filtered: filtered);
|
||||||
|
|
||||||
debugPrint('[AnalysisScreen] didChangeDependencies: '
|
debugPrint('[AnalysisScreen] didChangeDependencies: '
|
||||||
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
|
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
|
||||||
@@ -64,13 +74,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 구독 데이터의 해시값을 계산하여 변경 감지
|
/// 구독 데이터의 해시값을 계산하여 변경 감지
|
||||||
String _calculateDataHash(SubscriptionProvider provider) {
|
String _calculateDataHash(
|
||||||
final subscriptions = provider.subscriptions;
|
SubscriptionProvider provider, {
|
||||||
final buffer = StringBuffer();
|
List<SubscriptionModel>? filtered,
|
||||||
|
}) {
|
||||||
buffer.write(subscriptions.length);
|
final subscriptions =
|
||||||
buffer.write('_');
|
filtered ?? _filterSubscriptions(provider.subscriptions);
|
||||||
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
|
final buffer = StringBuffer()
|
||||||
|
..write(_filterType.name)
|
||||||
|
..write('_${_selectedCardId ?? 'all'}')
|
||||||
|
..write('_${subscriptions.length}');
|
||||||
|
|
||||||
for (final sub in subscriptions) {
|
for (final sub in subscriptions) {
|
||||||
buffer.write(
|
buffer.write(
|
||||||
@@ -80,6 +93,38 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
return buffer.toString();
|
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 {
|
Future<void> _loadData() async {
|
||||||
debugPrint('[AnalysisScreen] _loadData 호출됨');
|
debugPrint('[AnalysisScreen] _loadData 호출됨');
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -89,17 +134,25 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||||
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
|
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
|
||||||
final locale = localeProvider.locale.languageCode;
|
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');
|
debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
|
||||||
|
|
||||||
// 월별 데이터 계산 (로케일별 기본 통화로 환산)
|
// 월별 데이터 계산 (로케일별 기본 통화로 환산)
|
||||||
_monthlyData = await provider.getMonthlyExpenseData(locale: locale);
|
_monthlyData = await provider.getMonthlyExpenseData(
|
||||||
|
locale: locale,
|
||||||
|
subset: filteredSubscriptions,
|
||||||
|
);
|
||||||
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
|
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
|
||||||
|
|
||||||
// 현재 데이터 해시값 저장
|
// 현재 데이터 해시값 저장
|
||||||
_lastDataHash = _calculateDataHash(provider);
|
_lastDataHash =
|
||||||
|
_calculateDataHash(provider, filtered: filteredSubscriptions);
|
||||||
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
|
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
|
||||||
|
|
||||||
setState(() {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Provider를 직접 사용하여 변경 감지
|
// Provider를 직접 사용하여 변경 감지
|
||||||
@@ -142,6 +317,9 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final cardProvider = Provider.of<PaymentCardProvider>(context);
|
||||||
|
final filteredSubscriptions = _filterSubscriptions(subscriptions);
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
@@ -159,9 +337,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
|
|
||||||
const AnalysisScreenSpacer(),
|
const AnalysisScreenSpacer(),
|
||||||
|
|
||||||
|
_buildCardFilterSection(context, cardProvider),
|
||||||
|
|
||||||
|
const AnalysisScreenSpacer(),
|
||||||
|
|
||||||
// 1. 구독 비율 파이 차트
|
// 1. 구독 비율 파이 차트
|
||||||
SubscriptionPieChartCard(
|
SubscriptionPieChartCard(
|
||||||
subscriptions: subscriptions,
|
subscriptions: filteredSubscriptions,
|
||||||
animationController: _animationController,
|
animationController: _animationController,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -170,7 +352,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
// 2. 총 지출 요약 카드
|
// 2. 총 지출 요약 카드
|
||||||
TotalExpenseSummaryCard(
|
TotalExpenseSummaryCard(
|
||||||
key: ValueKey('total_expense_$_lastDataHash'),
|
key: ValueKey('total_expense_$_lastDataHash'),
|
||||||
subscriptions: subscriptions,
|
subscriptions: filteredSubscriptions,
|
||||||
totalExpense: _totalExpense,
|
totalExpense: _totalExpense,
|
||||||
animationController: _animationController,
|
animationController: _animationController,
|
||||||
),
|
),
|
||||||
@@ -189,6 +371,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
// 4. 이벤트 분석
|
// 4. 이벤트 분석
|
||||||
EventAnalysisCard(
|
EventAnalysisCard(
|
||||||
animationController: _animationController,
|
animationController: _animationController,
|
||||||
|
subscriptions: filteredSubscriptions,
|
||||||
),
|
),
|
||||||
|
|
||||||
// FloatingNavigationBar를 위한 충분한 하단 여백
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/app_lock_provider.dart';
|
import '../providers/app_lock_provider.dart';
|
||||||
import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
|
|
||||||
class AppLockScreen extends StatelessWidget {
|
class AppLockScreen extends StatelessWidget {
|
||||||
const AppLockScreen({super.key});
|
const AppLockScreen({super.key});
|
||||||
@@ -13,26 +13,26 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.lock_outline,
|
Icons.lock_outline,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: AppColors.navyGray,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
Text(
|
||||||
'앱이 잠겨 있습니다',
|
'앱이 잠겨 있습니다',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'생체 인증으로 잠금을 해제하세요',
|
'생체 인증으로 잠금을 해제하세요',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: AppColors.navyGray,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -41,15 +41,16 @@ class AppLockScreen extends StatelessWidget {
|
|||||||
final appLock = context.read<AppLockProvider>();
|
final appLock = context.read<AppLockProvider>();
|
||||||
final success = await appLock.authenticate();
|
final success = await appLock.authenticate();
|
||||||
if (!success && context.mounted) {
|
if (!success && context.mounted) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'인증에 실패했습니다. 다시 시도해주세요.',
|
'인증에 실패했습니다. 다시 시도해주세요.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.pureWhite,
|
color: cs.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: AppColors.dangerColor,
|
backgroundColor: cs.error,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/category_provider.dart';
|
import '../providers/category_provider.dart';
|
||||||
import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class CategoryManagementScreen extends StatefulWidget {
|
class CategoryManagementScreen extends StatefulWidget {
|
||||||
@@ -43,13 +43,13 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text(
|
title: Text(
|
||||||
'카테고리 관리',
|
'카테고리 관리',
|
||||||
style: TextStyle(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
color: AppColors.pureWhite,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: AppColors.primaryColor,
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
body: Consumer<CategoryProvider>(
|
body: Consumer<CategoryProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
@@ -66,10 +66,12 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '카테고리 이름',
|
labelText: '카테고리 이름',
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: AppColors.navyGray,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
@@ -81,11 +83,13 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: _selectedColor,
|
initialValue: _selectedColor,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '색상 선택',
|
labelText: '색상 선택',
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: AppColors.navyGray,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
@@ -93,32 +97,42 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
value: '#1976D2',
|
value: '#1976D2',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorBlue,
|
AppLocalizations.of(context).colorBlue,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#4CAF50',
|
value: '#4CAF50',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorGreen,
|
AppLocalizations.of(context).colorGreen,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#FF9800',
|
value: '#FF9800',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorOrange,
|
AppLocalizations.of(context).colorOrange,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#F44336',
|
value: '#F44336',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorRed,
|
AppLocalizations.of(context).colorRed,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#9C27B0',
|
value: '#9C27B0',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorPurple,
|
AppLocalizations.of(context).colorPurple,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -128,39 +142,51 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: _selectedIcon,
|
initialValue: _selectedIcon,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '아이콘 선택',
|
labelText: '아이콘 선택',
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: AppColors.navyGray,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
items: const [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'subscriptions',
|
value: 'subscriptions',
|
||||||
child: Text('구독',
|
child: Text('구독',
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'movie',
|
value: 'movie',
|
||||||
child: Text('영화',
|
child: Text('영화',
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'music_note',
|
value: 'music_note',
|
||||||
child: Text('음악',
|
child: Text('음악',
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'fitness_center',
|
value: 'fitness_center',
|
||||||
child: Text('운동',
|
child: Text('운동',
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'shopping_cart',
|
value: 'shopping_cart',
|
||||||
child: Text('쇼핑',
|
child: Text('쇼핑',
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(color: AppColors.darkNavy))),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface))),
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -171,12 +197,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _addCategory,
|
onPressed: _addCategory,
|
||||||
child: const Text(
|
child: const Text('카테고리 추가'),
|
||||||
'카테고리 추가',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.pureWhite,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -201,8 +222,8 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
title: Text(
|
title: Text(
|
||||||
provider.getLocalizedCategoryName(
|
provider.getLocalizedCategoryName(
|
||||||
context, category.name),
|
context, category.name),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import 'package:provider/provider.dart';
|
|||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../controllers/detail_screen_controller.dart';
|
import '../controllers/detail_screen_controller.dart';
|
||||||
import '../widgets/detail/detail_header_section.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_form_section.dart';
|
||||||
import '../widgets/detail/detail_event_section.dart';
|
import '../widgets/detail/detail_event_section.dart';
|
||||||
import '../widgets/detail/detail_url_section.dart';
|
import '../widgets/detail/detail_url_section.dart';
|
||||||
import '../widgets/detail/detail_action_buttons.dart';
|
import '../widgets/detail/detail_action_buttons.dart';
|
||||||
import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면
|
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면
|
||||||
@@ -50,7 +51,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
return ChangeNotifierProvider<DetailScreenController>.value(
|
return ChangeNotifierProvider<DetailScreenController>.value(
|
||||||
value: _controller,
|
value: _controller,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: AppColors.backgroundColor,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _controller.scrollController,
|
controller: _controller.scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -77,17 +78,16 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: Theme.of(context)
|
||||||
colors: [
|
.colorScheme
|
||||||
baseColor.withValues(alpha: 0.15),
|
.surfaceContainerHighest
|
||||||
baseColor.withValues(alpha: 0.08),
|
.withValues(alpha: 0.4),
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: baseColor.withValues(alpha: 0.2),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -111,9 +111,9 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)
|
AppLocalizations.of(context)
|
||||||
.changesAppliedAfterSave,
|
.changesAppliedAfterSave,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -121,6 +121,13 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
DetailPaymentInfoSection(
|
||||||
|
controller: _controller,
|
||||||
|
fadeAnimation: _controller.fadeAnimation!,
|
||||||
|
slideAnimation: _controller.slideAnimation!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 기본 정보 폼 섹션
|
// 기본 정보 폼 섹션
|
||||||
DetailFormSection(
|
DetailFormSection(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/app_lock_provider.dart';
|
import '../providers/app_lock_provider.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
|
import '../theme/color_scheme_ext.dart';
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
import 'analysis_screen.dart';
|
import 'analysis_screen.dart';
|
||||||
import 'app_lock_screen.dart';
|
import 'app_lock_screen.dart';
|
||||||
@@ -11,7 +12,6 @@ import 'settings_screen.dart';
|
|||||||
import 'sms_scan_screen.dart';
|
import 'sms_scan_screen.dart';
|
||||||
import '../utils/animation_controller_helper.dart';
|
import '../utils/animation_controller_helper.dart';
|
||||||
import '../widgets/floating_navigation_bar.dart';
|
import '../widgets/floating_navigation_bar.dart';
|
||||||
import '../widgets/glassmorphic_scaffold.dart';
|
|
||||||
import '../widgets/home_content.dart';
|
import '../widgets/home_content.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
@@ -162,33 +162,34 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
if (result == true) {
|
if (result == true) {
|
||||||
// 상단에 스낵바 표시
|
// 상단에 스낵바 표시
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.check_circle,
|
Icons.check_circle,
|
||||||
color: AppColors.pureWhite,
|
color: cs.onPrimary,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).subscriptionAdded,
|
AppLocalizations.of(context).subscriptionAdded,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.pureWhite,
|
color: cs.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: AppColors.successColor,
|
backgroundColor: cs.success,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: EdgeInsets.only(
|
margin: EdgeInsets.only(
|
||||||
top: MediaQuery.of(context).padding.top + 8, // 더 상단으로
|
top: MediaQuery.of(context).padding.top + 8,
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로
|
bottom: MediaQuery.of(context).size.height - 100,
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -223,8 +224,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final navigationProvider = context.watch<NavigationProvider>();
|
final navigationProvider = context.watch<NavigationProvider>();
|
||||||
|
|
||||||
// 메인 그라데이션 사용
|
// 그라데이션 제거: 단색 배경 사용
|
||||||
List<Color> backgroundGradient = AppColors.mainGradient;
|
|
||||||
|
|
||||||
// 현재 인덱스가 유효한지 확인
|
// 현재 인덱스가 유효한지 확인
|
||||||
int currentIndex = navigationProvider.currentIndex;
|
int currentIndex = navigationProvider.currentIndex;
|
||||||
@@ -232,25 +232,31 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
currentIndex = 0; // 추가 버튼은 홈으로 표시
|
currentIndex = 0; // 추가 버튼은 홈으로 표시
|
||||||
}
|
}
|
||||||
|
|
||||||
return GlassmorphicScaffold(
|
return Stack(
|
||||||
body: IndexedStack(
|
children: [
|
||||||
index: PlatformHelper.isIOS
|
Positioned.fill(
|
||||||
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
|
child: Container(color: Theme.of(context).colorScheme.surface),
|
||||||
: (currentIndex == 3
|
),
|
||||||
? 3
|
Scaffold(
|
||||||
: currentIndex == 4
|
extendBody: true,
|
||||||
? 4
|
extendBodyBehindAppBar: true,
|
||||||
: currentIndex), // Android: 기존 로직
|
body: IndexedStack(
|
||||||
children: _screens,
|
index: PlatformHelper.isIOS
|
||||||
),
|
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
|
||||||
backgroundGradient: backgroundGradient,
|
: (currentIndex == 3
|
||||||
useFloatingNavBar: true,
|
? 3
|
||||||
floatingNavBarIndex: navigationProvider.currentIndex,
|
: currentIndex == 4
|
||||||
onFloatingNavBarTapped: (index) {
|
? 4
|
||||||
_handleNavigation(index, context);
|
: currentIndex), // Android: 기존 로직
|
||||||
},
|
children: _screens,
|
||||||
enableParticles: false,
|
),
|
||||||
enableWaveAnimation: false,
|
),
|
||||||
|
FloatingNavigationBar(
|
||||||
|
selectedIndex: navigationProvider.currentIndex,
|
||||||
|
isVisible: true,
|
||||||
|
onItemTapped: (index) => _handleNavigation(index, context),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
lib/screens/payment_card_management_screen.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||||
import '../theme/app_colors.dart';
|
// Material colors only
|
||||||
import '../widgets/glassmorphism_card.dart';
|
// Glass 제거: Material 3 Card 사용
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../services/sms_service.dart';
|
import '../services/sms_service.dart';
|
||||||
@@ -92,12 +92,13 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.sms, size: 64, color: AppColors.primaryColor),
|
Icon(Icons.sms,
|
||||||
|
size: 64, color: Theme.of(context).colorScheme.primary),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
loc.smsPermissionTitle,
|
loc.smsPermissionTitle,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
color: AppColors.textPrimary,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -105,24 +106,39 @@ class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
|
|||||||
Text(
|
Text(
|
||||||
loc.smsPermissionRequired,
|
loc.smsPermissionRequired,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: AppColors.textSecondary),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
GlassmorphismCard(
|
Card(
|
||||||
padding: const EdgeInsets.all(16),
|
elevation: 1,
|
||||||
child: Column(
|
shape: RoundedRectangleBorder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
borderRadius: BorderRadius.circular(12),
|
||||||
children: [
|
side: BorderSide(
|
||||||
Text(loc.smsPermissionReasonTitle,
|
color: Theme.of(context)
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
.colorScheme
|
||||||
const SizedBox(height: 8),
|
.outline
|
||||||
Text(loc.smsPermissionReasonBody),
|
.withValues(alpha: 0.5),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Text(loc.smsPermissionScopeTitle,
|
),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
child: Padding(
|
||||||
const SizedBox(height: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
Text(loc.smsPermissionScopeBody),
|
child: Column(
|
||||||
],
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(loc.smsPermissionReasonTitle,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(loc.smsPermissionReasonBody),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(loc.smsPermissionScopeTitle,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(loc.smsPermissionScopeBody),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import '../widgets/sms_scan/scan_progress_widget.dart';
|
|||||||
import '../widgets/sms_scan/subscription_card_widget.dart';
|
import '../widgets/sms_scan/subscription_card_widget.dart';
|
||||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../widgets/payment_card/payment_card_form_sheet.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
|
import '../models/payment_card_suggestion.dart';
|
||||||
|
|
||||||
class SmsScanScreen extends StatefulWidget {
|
class SmsScanScreen extends StatefulWidget {
|
||||||
const SmsScanScreen({super.key});
|
const SmsScanScreen({super.key});
|
||||||
@@ -16,18 +19,21 @@ class SmsScanScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _SmsScanScreenState extends State<SmsScanScreen> {
|
class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||||
late SmsScanController _controller;
|
late SmsScanController _controller;
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = SmsScanController();
|
_controller = SmsScanController();
|
||||||
_controller.addListener(_handleControllerUpdate);
|
_controller.addListener(_handleControllerUpdate);
|
||||||
|
_scrollController = ScrollController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.removeListener(_handleControllerUpdate);
|
_controller.removeListener(_handleControllerUpdate);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,19 +96,72 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SubscriptionCardWidget(
|
SubscriptionCardWidget(
|
||||||
subscription: currentSubscription,
|
subscription: currentSubscription,
|
||||||
|
serviceNameController: _controller.serviceNameController,
|
||||||
websiteUrlController: _controller.websiteUrlController,
|
websiteUrlController: _controller.websiteUrlController,
|
||||||
selectedCategoryId: _controller.selectedCategoryId,
|
selectedCategoryId: _controller.selectedCategoryId,
|
||||||
onCategoryChanged: _controller.setSelectedCategoryId,
|
onCategoryChanged: _controller.setSelectedCategoryId,
|
||||||
onAdd: () => _controller.addCurrentSubscription(context),
|
selectedPaymentCardId: _controller.selectedPaymentCardId,
|
||||||
onSkip: () => _controller.skipCurrentSubscription(context),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/app_colors.dart';
|
// import '../theme/app_colors.dart';
|
||||||
import '../services/sms_service.dart';
|
import '../services/sms_service.dart';
|
||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
@@ -90,8 +90,6 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
(reduced ? 1200 : 2000); // 축소 시 더 짧게
|
(reduced ? 1200 : 2000); // 축소 시 더 짧게
|
||||||
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
||||||
|
|
||||||
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
|
||||||
|
|
||||||
_particles.add({
|
_particles.add({
|
||||||
'size': size,
|
'size': size,
|
||||||
'x': x,
|
'x': x,
|
||||||
@@ -99,7 +97,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
'opacity': opacity,
|
'opacity': opacity,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'delay': delay,
|
'delay': delay,
|
||||||
'color': AppColors.blueGradient[colorIndex],
|
// color computed at render from ColorScheme.primary
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,23 +135,15 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// 배경 그라디언트
|
// 단색 배경
|
||||||
Container(
|
Container(color: Theme.of(context).colorScheme.surface),
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
AppColors.dayGradient[0],
|
|
||||||
AppColors.dayGradient[1],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 글래스모피즘 오버레이
|
// 글래스모피즘 오버레이
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.pureWhite.withValues(alpha: 0.05),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.05),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Stack(
|
Stack(
|
||||||
@@ -180,11 +170,14 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
width: particle['size'],
|
width: particle['size'],
|
||||||
height: particle['size'],
|
height: particle['size'],
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: particle['color'],
|
color: Theme.of(context).colorScheme.primary,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: particle['color'].withValues(alpha: 0.3),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
spreadRadius: 1,
|
spreadRadius: 1,
|
||||||
),
|
),
|
||||||
@@ -193,45 +186,25 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}),
|
||||||
|
|
||||||
// 상단 원형 그라데이션
|
// 상단 원형 장식 제거(단색 배경 유지)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: -size.height * 0.2,
|
top: -size.height * 0.2,
|
||||||
right: -size.width * 0.2,
|
right: -size.width * 0.2,
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: size.width * 0.8,
|
width: size.width * 0.8,
|
||||||
height: size.width * 0.8,
|
height: size.width * 0.8,
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: RadialGradient(
|
|
||||||
colors: [
|
|
||||||
AppColors.pureWhite.withValues(alpha: 0.1),
|
|
||||||
AppColors.pureWhite.withValues(alpha: 0.0),
|
|
||||||
],
|
|
||||||
stops: const [0.2, 1.0],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 하단 원형 그라데이션
|
// 하단 원형 장식 제거
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: -size.height * 0.1,
|
bottom: -size.height * 0.1,
|
||||||
left: -size.width * 0.3,
|
left: -size.width * 0.3,
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: size.width * 0.9,
|
width: size.width * 0.9,
|
||||||
height: size.width * 0.9,
|
height: size.width * 0.9,
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: RadialGradient(
|
|
||||||
colors: [
|
|
||||||
AppColors.pureWhite.withValues(alpha: 0.07),
|
|
||||||
AppColors.pureWhite.withValues(alpha: 0.0),
|
|
||||||
],
|
|
||||||
stops: const [0.4, 1.0],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -271,62 +244,32 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
reduced: 8)),
|
reduced: 8)),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: Theme.of(context)
|
||||||
begin: Alignment.topLeft,
|
.colorScheme
|
||||||
end: Alignment.bottomRight,
|
.surface
|
||||||
colors: [
|
.withValues(alpha: 0.6),
|
||||||
AppColors.pureWhite
|
|
||||||
.withValues(alpha: 0.2),
|
|
||||||
AppColors.pureWhite
|
|
||||||
.withValues(alpha: 0.1),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(30),
|
BorderRadius.circular(30),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.pureWhite
|
color: Theme.of(context)
|
||||||
.withValues(alpha: 0.3),
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color:
|
|
||||||
AppColors.shadowBlack,
|
|
||||||
spreadRadius: 0,
|
|
||||||
blurRadius:
|
|
||||||
ReduceMotion.scale(
|
|
||||||
context,
|
|
||||||
normal: 30,
|
|
||||||
reduced: 12),
|
|
||||||
offset: const Offset(0, 10),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation:
|
animation:
|
||||||
_animationController,
|
_animationController,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return ShaderMask(
|
return Icon(
|
||||||
blendMode:
|
Icons
|
||||||
BlendMode.srcIn,
|
.subscriptions_outlined,
|
||||||
shaderCallback: (bounds) =>
|
size: 64,
|
||||||
const LinearGradient(
|
color: Theme.of(context)
|
||||||
colors: AppColors
|
.colorScheme
|
||||||
.blueGradient,
|
.primary,
|
||||||
begin:
|
|
||||||
Alignment.topLeft,
|
|
||||||
end: Alignment
|
|
||||||
.bottomRight,
|
|
||||||
).createShader(bounds),
|
|
||||||
child: Icon(
|
|
||||||
Icons
|
|
||||||
.subscriptions_outlined,
|
|
||||||
size: 64,
|
|
||||||
color:
|
|
||||||
Theme.of(context)
|
|
||||||
.primaryColor,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -356,7 +299,9 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.primaryColor
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
.withValues(alpha: 0.9),
|
.withValues(alpha: 0.9),
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
@@ -382,7 +327,9 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
AppLocalizations.of(context).appSubtitle,
|
AppLocalizations.of(context).appSubtitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: AppColors.primaryColor
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
.withValues(alpha: 0.7),
|
.withValues(alpha: 0.7),
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
@@ -404,18 +351,22 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
height: 60,
|
height: 60,
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.pureWhite
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
.withValues(alpha: 0.1),
|
.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.pureWhite
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
.withValues(alpha: 0.2),
|
.withValues(alpha: 0.2),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
color:
|
||||||
AppColors.pureWhite),
|
Theme.of(context).colorScheme.primary,
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -436,7 +387,10 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
'© 2025 NatureBridgeAI. All rights reserved.',
|
'© 2025 NatureBridgeAI. All rights reserved.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.pureWhite.withValues(alpha: 0.6),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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 {
|
class NotificationService {
|
||||||
static final FlutterLocalNotificationsPlugin _notifications =
|
static final FlutterLocalNotificationsPlugin _notifications =
|
||||||
@@ -17,6 +20,24 @@ class NotificationService {
|
|||||||
static const _reminderHourKey = 'reminder_hour';
|
static const _reminderHourKey = 'reminder_hour';
|
||||||
static const _reminderMinuteKey = 'reminder_minute';
|
static const _reminderMinuteKey = 'reminder_minute';
|
||||||
static const _dailyReminderKey = 'daily_reminder_enabled';
|
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;
|
static bool _initialized = false;
|
||||||
@@ -56,6 +77,33 @@ class NotificationService {
|
|||||||
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
||||||
|
|
||||||
await _notifications.initialize(initSettings);
|
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;
|
_initialized = true;
|
||||||
debugPrint('알림 서비스 초기화 완료');
|
debugPrint('알림 서비스 초기화 완료');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -122,20 +170,32 @@ class NotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 알림 모두 취소
|
final pendingRequests =
|
||||||
await cancelAllNotifications();
|
await _notifications.pendingNotificationRequests();
|
||||||
|
|
||||||
// 알림 설정 가져오기
|
|
||||||
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
||||||
if (!isPaymentEnabled) return;
|
if (!isPaymentEnabled) {
|
||||||
|
await _cancelOrphanedPaymentReminderNotifications(
|
||||||
|
const <String>{},
|
||||||
|
pendingRequests,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final reminderDays = await getReminderDays();
|
final reminderDays = await getReminderDays();
|
||||||
final reminderHour = await getReminderHour();
|
final reminderHour = await getReminderHour();
|
||||||
final reminderMinute = await getReminderMinute();
|
final reminderMinute = await getReminderMinute();
|
||||||
final isDailyReminder = await isDailyReminderEnabled();
|
final isDailyReminder = await isDailyReminderEnabled();
|
||||||
|
|
||||||
// 각 구독에 대해 알림 재설정
|
final activeSubscriptionIds =
|
||||||
|
subscriptions.map((subscription) => subscription.id).toSet();
|
||||||
|
|
||||||
for (final subscription in subscriptions) {
|
for (final subscription in subscriptions) {
|
||||||
|
await _cancelPaymentReminderNotificationsForSubscription(
|
||||||
|
subscription,
|
||||||
|
pendingRequests,
|
||||||
|
);
|
||||||
|
|
||||||
await schedulePaymentReminder(
|
await schedulePaymentReminder(
|
||||||
subscription: subscription,
|
subscription: subscription,
|
||||||
reminderDays: reminderDays,
|
reminderDays: reminderDays,
|
||||||
@@ -144,11 +204,78 @@ class NotificationService {
|
|||||||
isDailyReminder: isDailyReminder,
|
isDailyReminder: isDailyReminder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _cancelOrphanedPaymentReminderNotifications(
|
||||||
|
activeSubscriptionIds,
|
||||||
|
pendingRequests,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('알림 일정 재설정 중 오류 발생: $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 {
|
static Future<bool> requestPermission() async {
|
||||||
// 웹 플랫폼인 경우 false 반환
|
// 웹 플랫폼인 경우 false 반환
|
||||||
if (_isWeb) return false;
|
if (_isWeb) return false;
|
||||||
@@ -218,12 +345,70 @@ class NotificationService {
|
|||||||
return true; // 기본값
|
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({
|
static Future<void> scheduleNotification({
|
||||||
required int id,
|
required int id,
|
||||||
required String title,
|
required String title,
|
||||||
required String body,
|
required String body,
|
||||||
required DateTime scheduledDate,
|
required DateTime scheduledDate,
|
||||||
|
String? payload,
|
||||||
|
String? channelId,
|
||||||
}) async {
|
}) async {
|
||||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||||
if (_isWeb || !_initialized) {
|
if (_isWeb || !_initialized) {
|
||||||
@@ -232,15 +417,34 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const androidDetails = AndroidNotificationDetails(
|
final ctx = navigatorKey.currentContext;
|
||||||
'subscription_channel',
|
String channelName;
|
||||||
'구독 알림',
|
if (channelId == _expirationChannelId) {
|
||||||
channelDescription: '구독 관련 알림을 보여줍니다.',
|
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,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
|
autoCancel: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const iosDetails = DarwinNotificationDetails();
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
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(
|
await _notifications.zonedSchedule(
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
tz.TZDateTime.from(scheduledDate, location),
|
target,
|
||||||
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||||
uiLocalNotificationDateInterpretation:
|
androidScheduleMode: scheduleMode,
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
payload: payload,
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('알림 예약 중 오류 발생: $e');
|
debugPrint('알림 예약 중 오류 발생: $e');
|
||||||
@@ -307,23 +522,25 @@ class NotificationService {
|
|||||||
try {
|
try {
|
||||||
final notificationId = subscription.id.hashCode;
|
final notificationId = subscription.id.hashCode;
|
||||||
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
final ctx = navigatorKey.currentContext;
|
||||||
'subscription_channel',
|
final title = ctx != null
|
||||||
'구독 알림',
|
? AppLocalizations.of(ctx).expirationReminder
|
||||||
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
|
: 'Expiration Reminder';
|
||||||
importance: Importance.high,
|
|
||||||
priority: Priority.high,
|
|
||||||
);
|
|
||||||
|
|
||||||
const iosDetails = DarwinNotificationDetails(
|
final notificationDetails = NotificationDetails(
|
||||||
presentAlert: true,
|
android: AndroidNotificationDetails(
|
||||||
presentBadge: true,
|
_paymentChannelId,
|
||||||
presentSound: true,
|
title,
|
||||||
);
|
channelDescription: title,
|
||||||
|
importance: Importance.high,
|
||||||
const notificationDetails = NotificationDetails(
|
priority: Priority.high,
|
||||||
android: androidDetails,
|
autoCancel: false,
|
||||||
iOS: iosDetails,
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// 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(
|
await _notifications.zonedSchedule(
|
||||||
notificationId,
|
notificationId,
|
||||||
'구독 만료 알림',
|
title,
|
||||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
_buildExpirationBody(subscription),
|
||||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
fireAt,
|
||||||
notificationDetails,
|
notificationDetails,
|
||||||
uiLocalNotificationDateInterpretation:
|
androidScheduleMode: scheduleMode,
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||||
@@ -373,55 +610,18 @@ class NotificationService {
|
|||||||
|
|
||||||
static Future<void> schedulePaymentNotification(
|
static Future<void> schedulePaymentNotification(
|
||||||
SubscriptionModel subscription) async {
|
SubscriptionModel subscription) async {
|
||||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
if (_isWeb || !_initialized) return;
|
||||||
if (_isWeb || !_initialized) {
|
final reminderDays = await getReminderDays();
|
||||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
final hour = await getReminderHour();
|
||||||
return;
|
final minute = await getReminderMinute();
|
||||||
}
|
final daily = await isDailyReminderEnabled();
|
||||||
|
await schedulePaymentReminder(
|
||||||
try {
|
subscription: subscription,
|
||||||
final paymentDate = subscription.nextBillingDate;
|
reminderDays: reminderDays,
|
||||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
reminderHour: hour,
|
||||||
|
reminderMinute: minute,
|
||||||
// tz.local 초기화 확인 및 재시도
|
isDailyReminder: daily,
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> scheduleExpirationNotification(
|
static Future<void> scheduleExpirationNotification(
|
||||||
@@ -461,16 +661,20 @@ class NotificationService {
|
|||||||
tz.TZDateTime.from(reminderDate, location),
|
tz.TZDateTime.from(reminderDate, location),
|
||||||
const NotificationDetails(
|
const NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'expiration_channel',
|
_expirationChannelId,
|
||||||
'Expiration Notifications',
|
'Expiration Notifications',
|
||||||
channelDescription: 'Channel for subscription expiration reminders',
|
channelDescription: 'Channel for subscription expiration reminders',
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
|
autoCancel: false,
|
||||||
|
),
|
||||||
|
iOS: DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
uiLocalNotificationDateInterpretation:
|
androidScheduleMode: await _resolveAndroidScheduleMode(),
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||||
@@ -491,6 +695,9 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final locale = _getLocaleCode();
|
||||||
|
final title = _paymentReminderTitle(locale);
|
||||||
|
|
||||||
// tz.local 초기화 확인 및 재시도
|
// tz.local 초기화 확인 및 재시도
|
||||||
tz.Location location;
|
tz.Location location;
|
||||||
try {
|
try {
|
||||||
@@ -510,7 +717,7 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 기본 알림 예약 (지정된 일수 전)
|
// 기본 알림 예약 (지정된 일수 전)
|
||||||
final scheduledDate = subscription.nextBillingDate
|
final baseLocal = subscription.nextBillingDate
|
||||||
.subtract(Duration(days: reminderDays))
|
.subtract(Duration(days: reminderDays))
|
||||||
.copyWith(
|
.copyWith(
|
||||||
hour: reminderHour,
|
hour: reminderHour,
|
||||||
@@ -519,57 +726,65 @@ class NotificationService {
|
|||||||
millisecond: 0,
|
millisecond: 0,
|
||||||
microsecond: 0,
|
microsecond: 0,
|
||||||
);
|
);
|
||||||
|
final nowTz = tz.TZDateTime.now(location);
|
||||||
// 남은 일수에 따른 메시지 생성
|
var scheduledDate = tz.TZDateTime.from(baseLocal, location);
|
||||||
String daysText = '$reminderDays일 후';
|
if (kDebugMode) {
|
||||||
if (reminderDays == 1) {
|
debugPrint('[NotificationService] schedulePaymentReminder(base)'
|
||||||
daysText = '내일';
|
' 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;
|
final body = await _buildPaymentBody(subscription, daysText);
|
||||||
if (subscription.isEventActive &&
|
|
||||||
subscription.eventEndDate != null &&
|
final scheduleMode = await _resolveAndroidScheduleMode();
|
||||||
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
if (kDebugMode) {
|
||||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
debugPrint(
|
||||||
// 이벤트가 결제일 전에 종료되는 경우
|
'[NotificationService] schedulePaymentReminder(base) scheduleMode=$scheduleMode');
|
||||||
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 결제 예정입니다.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
subscription.id.hashCode,
|
subscription.id.hashCode,
|
||||||
'구독 결제 예정 알림',
|
title,
|
||||||
notificationBody,
|
body,
|
||||||
tz.TZDateTime.from(scheduledDate, location),
|
scheduledDate,
|
||||||
const NotificationDetails(
|
NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'subscription_channel',
|
_paymentChannelId,
|
||||||
'Subscription Notifications',
|
title,
|
||||||
channelDescription: 'Channel for subscription reminders',
|
channelDescription: title,
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
|
autoCancel: false,
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
),
|
),
|
||||||
iOS: DarwinNotificationDetails(),
|
|
||||||
),
|
),
|
||||||
uiLocalNotificationDateInterpretation:
|
androidScheduleMode: scheduleMode,
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
payload: _paymentPayload(subscription.id),
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
||||||
if (isDailyReminder && reminderDays >= 2) {
|
if (isDailyReminder && reminderDays >= 2) {
|
||||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||||
final dailyDate =
|
final dailyLocal =
|
||||||
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
||||||
hour: reminderHour,
|
hour: reminderHour,
|
||||||
minute: reminderMinute,
|
minute: reminderMinute,
|
||||||
@@ -577,50 +792,50 @@ class NotificationService {
|
|||||||
millisecond: 0,
|
millisecond: 0,
|
||||||
microsecond: 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일 후';
|
final remainingDaysText = _daysInText(locale, i);
|
||||||
if (i == 1) {
|
|
||||||
remainingDaysText = '내일';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 날짜에 대한 이벤트 종료 확인
|
// 각 날짜에 대한 이벤트 종료 확인
|
||||||
String dailyNotificationBody;
|
final dailyNotificationBody =
|
||||||
if (subscription.isEventActive &&
|
await _buildPaymentBody(subscription, remainingDaysText);
|
||||||
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 결제 예정입니다.';
|
|
||||||
}
|
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||||
'구독 결제 예정 알림',
|
title,
|
||||||
dailyNotificationBody,
|
dailyNotificationBody,
|
||||||
tz.TZDateTime.from(dailyDate, location),
|
dailyDate,
|
||||||
const NotificationDetails(
|
NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'subscription_channel',
|
_paymentChannelId,
|
||||||
'Subscription Notifications',
|
title,
|
||||||
channelDescription: 'Channel for subscription reminders',
|
channelDescription: title,
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
|
autoCancel: false,
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
),
|
),
|
||||||
iOS: DarwinNotificationDetails(),
|
|
||||||
),
|
),
|
||||||
uiLocalNotificationDateInterpretation:
|
androidScheduleMode: scheduleMode,
|
||||||
UILocalNotificationDateInterpretation.absoluteTime,
|
payload: _paymentPayload(subscription.id),
|
||||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
static String getNotificationBody(String serviceName, double amount) {
|
||||||
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
lib/services/sms_scan/sms_scan_result.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import '../../models/subscription.dart';
|
import '../../models/subscription.dart';
|
||||||
import '../../models/subscription_model.dart';
|
import 'sms_scan_result.dart';
|
||||||
|
|
||||||
class SubscriptionConverter {
|
class SubscriptionConverter {
|
||||||
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
||||||
List<Subscription> convertModelsToSubscriptions(
|
List<Subscription> convertResultsToSubscriptions(
|
||||||
List<SubscriptionModel> models) {
|
List<SmsScanResult> results) {
|
||||||
final result = <Subscription>[];
|
final result = <Subscription>[];
|
||||||
|
|
||||||
for (var model in models) {
|
for (final smsResult in results) {
|
||||||
try {
|
try {
|
||||||
final subscription = _convertSingle(model);
|
final subscription = _convertSingle(smsResult);
|
||||||
result.add(subscription);
|
result.add(subscription);
|
||||||
|
|
||||||
// 개발 편의를 위한 디버그 로그
|
// 개발 편의를 위한 디버그 로그
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
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) {
|
} catch (e) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('모델 변환 중 오류 발생: $e');
|
print('모델 변환 중 오류 발생: $e');
|
||||||
@@ -26,7 +26,8 @@ class SubscriptionConverter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 단일 모델 변환
|
// 단일 모델 변환
|
||||||
Subscription _convertSingle(SubscriptionModel model) {
|
Subscription _convertSingle(SmsScanResult result) {
|
||||||
|
final model = result.model;
|
||||||
return Subscription(
|
return Subscription(
|
||||||
id: model.id,
|
id: model.id,
|
||||||
serviceName: model.serviceName,
|
serviceName: model.serviceName,
|
||||||
@@ -38,6 +39,9 @@ class SubscriptionConverter {
|
|||||||
lastPaymentDate: model.lastPaymentDate,
|
lastPaymentDate: model.lastPaymentDate,
|
||||||
websiteUrl: model.websiteUrl,
|
websiteUrl: model.websiteUrl,
|
||||||
currency: model.currency,
|
currency: model.currency,
|
||||||
|
paymentCardId: model.paymentCardId,
|
||||||
|
paymentCardSuggestion: result.cardSuggestion,
|
||||||
|
rawMessage: result.rawMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb, compute;
|
import 'package:flutter/foundation.dart' show kIsWeb, compute;
|
||||||
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
@@ -5,11 +7,14 @@ import '../utils/logger.dart';
|
|||||||
import '../temp/test_sms_data.dart';
|
import '../temp/test_sms_data.dart';
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../services/subscription_url_matcher.dart';
|
||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
|
import '../utils/business_day_util.dart';
|
||||||
|
import '../services/sms_scan/sms_scan_result.dart';
|
||||||
|
import '../models/payment_card_suggestion.dart';
|
||||||
|
|
||||||
class SmsScanner {
|
class SmsScanner {
|
||||||
final SmsQuery _query = SmsQuery();
|
final SmsQuery _query = SmsQuery();
|
||||||
|
|
||||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
Future<List<SmsScanResult>> scanForSubscriptions() async {
|
||||||
try {
|
try {
|
||||||
List<dynamic> smsList;
|
List<dynamic> smsList;
|
||||||
Log.d('SmsScanner: 스캔 시작');
|
Log.d('SmsScanner: 스캔 시작');
|
||||||
@@ -35,38 +40,41 @@ class SmsScanner {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
final filteredSms = smsList
|
||||||
final List<SubscriptionModel> subscriptions = [];
|
.whereType<Map<String, dynamic>>()
|
||||||
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
|
.where(_isEligibleSubscriptionSms)
|
||||||
|
.toList();
|
||||||
|
|
||||||
// 서비스명별로 SMS 메시지 그룹화
|
Log.d(
|
||||||
for (final sms in smsList) {
|
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}건');
|
||||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
|
||||||
if (!serviceGroups.containsKey(serviceName)) {
|
if (filteredSms.isEmpty) {
|
||||||
serviceGroups[serviceName] = [];
|
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
|
||||||
}
|
return [];
|
||||||
serviceGroups[serviceName]!.add(sms);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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회 이상 반복된 서비스만 구독으로 간주
|
final result =
|
||||||
if (entry.value.length >= 2) {
|
_parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
|
||||||
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
if (result != null) {
|
||||||
final subscription = _parseSms(serviceSms, entry.value.length);
|
Log.i(
|
||||||
if (subscription != null) {
|
'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}');
|
||||||
Log.i(
|
subscriptions.add(result);
|
||||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
|
||||||
subscriptions.add(subscription);
|
|
||||||
} else {
|
|
||||||
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +114,7 @@ class SmsScanner {
|
|||||||
|
|
||||||
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
|
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
|
||||||
|
|
||||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||||
try {
|
try {
|
||||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||||
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||||
@@ -134,7 +142,11 @@ class SmsScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DateTime? nextBillingDate;
|
DateTime? nextBillingDate;
|
||||||
if (nextBillingDateStr != null) {
|
// 외부에서 계산된 다음 결제일이 있으면 우선 사용
|
||||||
|
final overrideNext = sms['overrideNextBillingDate'] as String?;
|
||||||
|
if (overrideNext != null) {
|
||||||
|
nextBillingDate = DateTime.tryParse(overrideNext);
|
||||||
|
} else if (nextBillingDateStr != null) {
|
||||||
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
|
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,10 +158,14 @@ class SmsScanner {
|
|||||||
|
|
||||||
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
|
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
|
||||||
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
|
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
|
||||||
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
|
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
|
||||||
billingCycle);
|
billingCycle,
|
||||||
|
);
|
||||||
|
// 주말/공휴일 보정
|
||||||
|
adjustedNextBillingDate =
|
||||||
|
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
|
||||||
|
|
||||||
return SubscriptionModel(
|
final model = SubscriptionModel(
|
||||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
serviceName: serviceName,
|
serviceName: serviceName,
|
||||||
monthlyCost: monthlyCost,
|
monthlyCost: monthlyCost,
|
||||||
@@ -161,11 +177,84 @@ class SmsScanner {
|
|||||||
websiteUrl: _extractWebsiteUrl(serviceName),
|
websiteUrl: _extractWebsiteUrl(serviceName),
|
||||||
currency: currency, // 통화 단위 설정
|
currency: currency, // 통화 단위 설정
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final suggestion = _extractPaymentCardSuggestion(message);
|
||||||
|
return SmsScanResult(
|
||||||
|
model: model,
|
||||||
|
cardSuggestion: suggestion,
|
||||||
|
rawMessage: message,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
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 _calculateNextBillingDate(
|
||||||
DateTime billingDate, String billingCycle) {
|
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') {
|
} else if (billingCycle == 'yearly') {
|
||||||
// 올해의 결제일이 지났는지 확인
|
// 올해의 결제일이 지났는지 확인
|
||||||
final thisYearBilling =
|
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 파서 =====
|
// ===== Isolate 오프로딩용 Top-level 파서 =====
|
||||||
|
|
||||||
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||||
List<Map<String, dynamic>> _parseRawSmsBatch(
|
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||||
List<Map<String, dynamic>> messages) {
|
List<Map<String, dynamic>> messages) {
|
||||||
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
|
|
||||||
const subscriptionKeywords = [
|
|
||||||
'구독',
|
|
||||||
'결제',
|
|
||||||
'정기결제',
|
|
||||||
'자동결제',
|
|
||||||
'월정액',
|
|
||||||
'subscription',
|
|
||||||
'payment',
|
|
||||||
'billing',
|
|
||||||
'charge',
|
|
||||||
'넷플릭스',
|
|
||||||
'Netflix',
|
|
||||||
'유튜브',
|
|
||||||
'YouTube',
|
|
||||||
'Spotify',
|
|
||||||
'멜론',
|
|
||||||
'웨이브',
|
|
||||||
'Disney+',
|
|
||||||
'디즈니플러스',
|
|
||||||
'Apple',
|
|
||||||
'Microsoft',
|
|
||||||
'GitHub',
|
|
||||||
'Adobe',
|
|
||||||
'Amazon'
|
|
||||||
];
|
|
||||||
|
|
||||||
final amountPatterns = <RegExp>[
|
final amountPatterns = <RegExp>[
|
||||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
|
||||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
|
||||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
|
||||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
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>>[];
|
final results = <Map<String, dynamic>>[];
|
||||||
@@ -324,28 +452,26 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
|
|||||||
final dateMillis =
|
final dateMillis =
|
||||||
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
||||||
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
|
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 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 billingCycle = _isoExtractBillingCycle(body);
|
||||||
final nextBillingDate =
|
final nextBillingDate =
|
||||||
_isoCalculateNextBillingFromDate(date, billingCycle);
|
_isoCalculateNextBillingFromDate(date, billingCycle);
|
||||||
|
final normalizedBody = _isoNormalizeBody(body);
|
||||||
|
|
||||||
results.add({
|
results.add({
|
||||||
'serviceName': serviceName,
|
'serviceName': serviceName,
|
||||||
|
'address': sender,
|
||||||
'monthlyCost': amount,
|
'monthlyCost': amount,
|
||||||
'billingCycle': billingCycle,
|
'billingCycle': billingCycle,
|
||||||
'message': body,
|
'message': body,
|
||||||
|
'normalizedBody': normalizedBody,
|
||||||
'nextBillingDate': nextBillingDate.toIso8601String(),
|
'nextBillingDate': nextBillingDate.toIso8601String(),
|
||||||
'previousPaymentDate': date.toIso8601String(),
|
'previousPaymentDate': date.toIso8601String(),
|
||||||
|
'isPaymentLike': isPaymentLike,
|
||||||
|
'isBlocked': isBlocked,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +534,23 @@ String _isoExtractBillingCycle(String body) {
|
|||||||
return 'monthly';
|
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 _isoCalculateNextBillingFromDate(
|
||||||
DateTime lastDate, String billingCycle) {
|
DateTime lastDate, String billingCycle) {
|
||||||
switch (billingCycle) {
|
switch (billingCycle) {
|
||||||
@@ -421,3 +564,260 @@ DateTime _isoCalculateNextBillingFromDate(
|
|||||||
return lastDate.add(const Duration(days: 30));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
/// URL Matcher 패키지의 export 파일
|
// URL Matcher 패키지의 export 파일
|
||||||
export 'models/service_info.dart';
|
export 'models/service_info.dart';
|
||||||
|
|||||||
@@ -166,6 +166,21 @@ class TestSmsData {
|
|||||||
'message':
|
'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}'
|
'[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}'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)
|
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)
|
||||||
|
|||||||
@@ -10,114 +10,119 @@ class AdaptiveTheme {
|
|||||||
|
|
||||||
/// 다크 테마
|
/// 다크 테마
|
||||||
static ThemeData get darkTheme {
|
static ThemeData get darkTheme {
|
||||||
|
const scheme = ColorScheme.dark(
|
||||||
|
primary: AppColors.primaryColor,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
secondary: AppColors.secondaryColor,
|
||||||
|
tertiary: AppColors.infoColor,
|
||||||
|
error: AppColors.errorColor,
|
||||||
|
surface: Color(0xFF1E1E1E),
|
||||||
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: scheme,
|
||||||
primary: AppColors.primaryColor,
|
|
||||||
onPrimary: Colors.white,
|
|
||||||
secondary: AppColors.secondaryColor,
|
|
||||||
tertiary: AppColors.infoColor,
|
|
||||||
error: AppColors.dangerColor,
|
|
||||||
surface: Color(0xFF1E1E1E),
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: scheme.surface,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.3),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: Colors.white.withValues(alpha: 0.1), width: 0.5),
|
color: const Color(0xFFFFFFFF).withValues(alpha: 0.08),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
),
|
),
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: const Color(0xFF1E1E1E),
|
backgroundColor: scheme.surface,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: scheme.onSurface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: const TextStyle(
|
// title/icon colors inherit from foregroundColor
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
),
|
|
||||||
iconTheme: IconThemeData(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textTheme: TextTheme(
|
|
||||||
headlineLarge: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
headlineMedium: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
headlineSmall: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.25,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
|
||||||
titleLarge: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
titleMedium: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.1,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
titleSmall: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
bodyLarge: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
bodyMedium: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
bodySmall: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.5),
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
textTheme: ThemeData.dark(useMaterial3: true)
|
||||||
|
.textTheme
|
||||||
|
.copyWith(
|
||||||
|
headlineLarge: const TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
headlineMedium: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
headlineSmall: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.25,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
titleLarge: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
titleMedium: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
titleSmall: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
bodyLarge: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
bodyMedium: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
bodySmall: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
labelLarge: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
labelMedium: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
labelSmall: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2A2A2A),
|
fillColor: scheme.surface,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@@ -126,33 +131,31 @@ class AdaptiveTheme {
|
|||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide:
|
borderSide: BorderSide(color: scheme.outline, width: 1),
|
||||||
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
|
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide:
|
borderSide: BorderSide(color: scheme.primary, width: 1.5),
|
||||||
const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
|
||||||
),
|
),
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
|
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||||
),
|
),
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
color: scheme.onSurfaceVariant,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: Colors.white.withValues(alpha: 0.5),
|
color: scheme.onSurfaceVariant,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryColor,
|
backgroundColor: scheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: scheme.onPrimary,
|
||||||
minimumSize: const Size(0, 48),
|
minimumSize: const Size(0, 48),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -161,8 +164,66 @@ class AdaptiveTheme {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return scheme.primary;
|
||||||
|
}
|
||||||
|
return scheme.onSurfaceVariant;
|
||||||
|
}),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return scheme.primary.withValues(alpha: 0.5);
|
||||||
|
}
|
||||||
|
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
checkboxTheme: CheckboxThemeData(
|
||||||
|
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return scheme.primary;
|
||||||
|
}
|
||||||
|
return Colors.transparent;
|
||||||
|
}),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
side: BorderSide(color: scheme.outline, width: 1.5),
|
||||||
|
),
|
||||||
|
radioTheme: RadioThemeData(
|
||||||
|
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return scheme.primary;
|
||||||
|
}
|
||||||
|
return scheme.onSurfaceVariant;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
sliderTheme: SliderThemeData(
|
||||||
|
activeTrackColor: scheme.primary,
|
||||||
|
inactiveTrackColor: scheme.onSurfaceVariant,
|
||||||
|
thumbColor: scheme.primary,
|
||||||
|
overlayColor: scheme.primary.withValues(alpha: 0.5),
|
||||||
|
trackHeight: 4,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||||
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||||
|
),
|
||||||
|
tabBarTheme: TabBarThemeData(
|
||||||
|
labelColor: scheme.primary,
|
||||||
|
unselectedLabelColor: scheme.onSurfaceVariant,
|
||||||
|
indicatorColor: scheme.primary,
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
unselectedLabelStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: Colors.white.withValues(alpha: 0.1),
|
color: scheme.outline,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 16,
|
space: 16,
|
||||||
),
|
),
|
||||||
@@ -171,19 +232,15 @@ class AdaptiveTheme {
|
|||||||
|
|
||||||
/// OLED 최적화 다크 테마
|
/// OLED 최적화 다크 테마
|
||||||
static ThemeData get oledTheme {
|
static ThemeData get oledTheme {
|
||||||
return darkTheme.copyWith(
|
final base = darkTheme;
|
||||||
|
const oledSurface = Color(0xFF0A0A0A);
|
||||||
|
return base.copyWith(
|
||||||
scaffoldBackgroundColor: Colors.black,
|
scaffoldBackgroundColor: Colors.black,
|
||||||
colorScheme: darkTheme.colorScheme.copyWith(
|
colorScheme: base.colorScheme.copyWith(surface: oledSurface),
|
||||||
surface: const Color(0xFF0A0A0A),
|
cardTheme: base.cardTheme.copyWith(color: oledSurface),
|
||||||
),
|
appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
|
||||||
cardTheme: darkTheme.cardTheme.copyWith(
|
inputDecorationTheme: base.inputDecorationTheme.copyWith(
|
||||||
color: const Color(0xFF0A0A0A),
|
fillColor: oledSurface,
|
||||||
),
|
|
||||||
appBarTheme: darkTheme.appBarTheme.copyWith(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
),
|
|
||||||
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
|
|
||||||
fillColor: const Color(0xFF0A0A0A),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ class AppColors {
|
|||||||
static const successColor = Color(0xFF38BDF8); // 소프트 민트
|
static const successColor = Color(0xFF38BDF8); // 소프트 민트
|
||||||
static const infoColor = Color(0xFF6366F1); // 인디고
|
static const infoColor = Color(0xFF6366F1); // 인디고
|
||||||
static const warningColor = Color(0xFFF59E0B); // 앰버
|
static const warningColor = Color(0xFFF59E0B); // 앰버
|
||||||
static const dangerColor = Color(0xFFF472B6); // 핑크 액센트
|
static const dangerColor = Color(0xFFF472B6); // 핑크 액센트 (액센트 용도)
|
||||||
|
static const errorColor = Color(0xFFEF4444); // 레드 (오류 용도)
|
||||||
|
|
||||||
// 배경색
|
// 배경색
|
||||||
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
|
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
|
||||||
@@ -31,27 +32,7 @@ class AppColors {
|
|||||||
// 그림자 (color.md 가이드)
|
// 그림자 (color.md 가이드)
|
||||||
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
|
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
|
||||||
|
|
||||||
// 그라데이션 컬러 - 다양한 효과를 위한 조합
|
// (그라데이션 컬러 제거됨)
|
||||||
static const List<Color> blueGradient = [
|
|
||||||
Color(0xFF2563EB), // 딥 블루
|
|
||||||
Color(0xFF60A5FA) // 스카이 블루
|
|
||||||
];
|
|
||||||
static const List<Color> tealGradient = [
|
|
||||||
Color(0xFF14B8A6),
|
|
||||||
Color(0xFF0D9488)
|
|
||||||
];
|
|
||||||
static const List<Color> purpleGradient = [
|
|
||||||
Color(0xFF8B5CF6),
|
|
||||||
Color(0xFF7C3AED)
|
|
||||||
];
|
|
||||||
static const List<Color> amberGradient = [
|
|
||||||
Color(0xFFF59E0B),
|
|
||||||
Color(0xFFD97706)
|
|
||||||
];
|
|
||||||
static const List<Color> roseGradient = [
|
|
||||||
Color(0xFFF43F5E),
|
|
||||||
Color(0xFFE11D48)
|
|
||||||
];
|
|
||||||
|
|
||||||
// Glassmorphism 효과를 위한 색상
|
// Glassmorphism 효과를 위한 색상
|
||||||
static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
|
static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
|
||||||
@@ -66,47 +47,9 @@ class AppColors {
|
|||||||
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
|
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
|
||||||
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
|
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
|
||||||
|
|
||||||
// 백드롭 블러 효과를 위한 그라디언트
|
// (백드롭 블러 그라데이션 제거됨)
|
||||||
static const List<Color> glassGradient = [
|
|
||||||
Color(0x33FFFFFF), // 20% white
|
|
||||||
Color(0x1AFFFFFF), // 10% white
|
|
||||||
];
|
|
||||||
|
|
||||||
static const List<Color> glassGradientDark = [
|
// (메인/액센트 그라데이션 제거됨)
|
||||||
Color(0x1A000000), // 10% black
|
|
||||||
Color(0x0F000000), // 6% black
|
|
||||||
];
|
|
||||||
|
|
||||||
// 메인 그라데이션
|
// (시간대별 배경 그라데이션 제거됨)
|
||||||
static const List<Color> mainGradient = [
|
|
||||||
Color(0xFF2563EB), // 딥 블루
|
|
||||||
Color(0xFF60A5FA), // 스카이 블루
|
|
||||||
Color(0xFFE0E7EF), // 라이트 그레이
|
|
||||||
];
|
|
||||||
|
|
||||||
static const List<Color> accentGradient = [
|
|
||||||
Color(0xFF38BDF8), // 소프트 민트
|
|
||||||
Color(0xFF60A5FA), // 스카이 블루
|
|
||||||
];
|
|
||||||
|
|
||||||
// 시간대별 배경 그라디언트
|
|
||||||
static const List<Color> morningGradient = [
|
|
||||||
Color(0xFFFED7AA), // 따뜻한 오렌지
|
|
||||||
Color(0xFFFBBF24), // 부드러운 노랑
|
|
||||||
];
|
|
||||||
|
|
||||||
static const List<Color> dayGradient = [
|
|
||||||
Color(0xFFDDEAFC), // 연한 하늘색
|
|
||||||
Color(0xFFBFDBFE), // 맑은 파랑
|
|
||||||
];
|
|
||||||
|
|
||||||
static const List<Color> eveningGradient = [
|
|
||||||
Color(0xFFFCA5A5), // 부드러운 핑크
|
|
||||||
Color(0xFFC084FC), // 연한 보라
|
|
||||||
];
|
|
||||||
|
|
||||||
static const List<Color> nightGradient = [
|
|
||||||
Color(0xFF4338CA), // 깊은 인디고
|
|
||||||
Color(0xFF1E1B4B), // 다크 네이비
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,354 +2,320 @@ import 'package:flutter/material.dart';
|
|||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static ThemeData lightTheme = ThemeData(
|
static ThemeData lightTheme = (() {
|
||||||
useMaterial3: true,
|
// Color scheme for light theme
|
||||||
colorScheme: const ColorScheme.light(
|
const scheme = ColorScheme.light(
|
||||||
primary: AppColors.primaryColor,
|
primary: AppColors.primaryColor,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
secondary: AppColors.secondaryColor,
|
secondary: AppColors.secondaryColor,
|
||||||
tertiary: AppColors.infoColor,
|
tertiary: AppColors.infoColor,
|
||||||
error: AppColors.dangerColor,
|
error: AppColors.errorColor,
|
||||||
surface: AppColors.surfaceColor,
|
surface: AppColors.surfaceColor,
|
||||||
),
|
);
|
||||||
|
|
||||||
// 기본 배경색
|
return ThemeData(
|
||||||
scaffoldBackgroundColor: AppColors.backgroundColor,
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
|
||||||
// 카드 스타일 - 글래스모피즘 효과
|
// 기본 배경색
|
||||||
cardTheme: CardThemeData(
|
scaffoldBackgroundColor: AppColors.backgroundColor,
|
||||||
color: AppColors.glassCard,
|
|
||||||
elevation: 0,
|
|
||||||
shadowColor: AppColors.shadowBlack,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
side: const BorderSide(color: AppColors.glassBorder, width: 1),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 앱바 스타일 - 글래스모피즘 디자인
|
// 카드 스타일 - Material 3 표면 중심
|
||||||
appBarTheme: const AppBarTheme(
|
cardTheme: CardThemeData(
|
||||||
backgroundColor: Colors.transparent,
|
elevation: 1,
|
||||||
foregroundColor: AppColors.textPrimary,
|
|
||||||
elevation: 0,
|
|
||||||
centerTitle: false,
|
|
||||||
titleTextStyle: TextStyle(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
),
|
|
||||||
iconTheme: IconThemeData(
|
|
||||||
color: AppColors.primaryColor,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 타이포그래피 - Metronic Tailwind 스타일
|
|
||||||
textTheme: const TextTheme(
|
|
||||||
// 헤드라인 - 페이지 제목
|
|
||||||
headlineLarge: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
headlineMedium: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
headlineSmall: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.25,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
|
||||||
|
|
||||||
// 타이틀 - 카드, 섹션 제목
|
|
||||||
titleLarge: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
titleMedium: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.1,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
titleSmall: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
|
|
||||||
// 본문 텍스트
|
|
||||||
bodyLarge: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
bodyMedium: TextStyle(
|
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
bodySmall: TextStyle(
|
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
|
|
||||||
// 라벨 텍스트
|
|
||||||
labelLarge: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
labelMedium: TextStyle(
|
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
labelSmall: TextStyle(
|
|
||||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 입력 필드 스타일 - 글래스모피즘 디자인
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
filled: true,
|
|
||||||
fillColor: AppColors.glassBackground,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: const BorderSide(color: AppColors.textSecondary, width: 1),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
|
||||||
),
|
|
||||||
errorBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
|
|
||||||
),
|
|
||||||
focusedErrorBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5),
|
|
||||||
),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
hintStyle: const TextStyle(
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
),
|
|
||||||
errorStyle: const TextStyle(
|
|
||||||
color: AppColors.dangerColor,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 버튼 스타일 - 프라이머리 버튼
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
minimumSize: const Size(0, 48),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 앱바 스타일 - 기본 M3 사용(투명 배경 유지)
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
textStyle: const TextStyle(
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
|
||||||
|
// 타이포그래피 - Material 3 + onSurface 정렬
|
||||||
|
textTheme: ThemeData.light(useMaterial3: true)
|
||||||
|
.textTheme
|
||||||
|
.copyWith(
|
||||||
|
headlineLarge: const TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
headlineMedium: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
headlineSmall: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.25,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
titleLarge: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
titleMedium: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
titleSmall: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
bodyLarge: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
bodyMedium: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
bodySmall: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
labelLarge: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
labelMedium: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
labelSmall: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.apply(
|
||||||
|
// 본문/헤드라인 공통 색상은 onSurface로 적용
|
||||||
|
bodyColor: scheme.onSurface,
|
||||||
|
displayColor: scheme.onSurface,
|
||||||
|
),
|
||||||
|
|
||||||
|
// 입력 필드 스타일 - M3 surface/outline 기반
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: scheme.surface,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.outline, width: 1),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.primary, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: scheme.error, width: 1.5),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
errorStyle: TextStyle(
|
||||||
|
color: scheme.error,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 버튼 스타일 - 프라이머리 버튼
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
foregroundColor: scheme.onPrimary,
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 텍스트 버튼 스타일
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: scheme.primary,
|
||||||
|
minimumSize: const Size(0, 40),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
// Text style inherits from theme.labelLarge
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 아웃라인 버튼 스타일
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: scheme.primary,
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
side: BorderSide(color: scheme.outline, width: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// FAB 스타일
|
||||||
|
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
foregroundColor: scheme.onPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
elevation: 2,
|
||||||
|
extendedPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
extendedTextStyle: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// 텍스트 버튼 스타일
|
// 스위치 스타일 (공통 테마)
|
||||||
textButtonTheme: TextButtonThemeData(
|
switchTheme: SwitchThemeData(
|
||||||
style: TextButton.styleFrom(
|
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
foregroundColor: AppColors.primaryColor,
|
if (states.contains(WidgetState.selected)) {
|
||||||
minimumSize: const Size(0, 40),
|
return scheme.primary;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
}
|
||||||
|
return scheme.onSurfaceVariant; // OFF 썸을 명확하게
|
||||||
|
}),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return scheme.primary.withValues(alpha: 0.5);
|
||||||
|
}
|
||||||
|
// OFF 트랙 대비 강화
|
||||||
|
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 체크박스 스타일
|
||||||
|
checkboxTheme: CheckboxThemeData(
|
||||||
|
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return scheme.primary;
|
||||||
|
}
|
||||||
|
return Colors.transparent;
|
||||||
|
}),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
textStyle: const TextStyle(
|
side: BorderSide(color: scheme.outline, width: 1.5),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 라디오 버튼 스타일
|
||||||
|
radioTheme: RadioThemeData(
|
||||||
|
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return scheme.primary;
|
||||||
|
}
|
||||||
|
return scheme.onSurfaceVariant;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 슬라이더 스타일
|
||||||
|
sliderTheme: SliderThemeData(
|
||||||
|
activeTrackColor: scheme.primary,
|
||||||
|
inactiveTrackColor: scheme.onSurfaceVariant,
|
||||||
|
thumbColor: scheme.primary,
|
||||||
|
overlayColor: scheme.primary.withValues(alpha: 0.3),
|
||||||
|
trackHeight: 4,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||||
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 탭바 스타일
|
||||||
|
tabBarTheme: TabBarThemeData(
|
||||||
|
labelColor: scheme.primary,
|
||||||
|
unselectedLabelColor: scheme.onSurfaceVariant,
|
||||||
|
indicatorColor: scheme.primary,
|
||||||
|
labelStyle: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
),
|
),
|
||||||
),
|
unselectedLabelStyle: const TextStyle(
|
||||||
),
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
// 아웃라인 버튼 스타일
|
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.primaryColor,
|
|
||||||
minimumSize: const Size(0, 48),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
side: const BorderSide(color: AppColors.secondaryColor, width: 1),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// FAB 스타일
|
// 디바이더 스타일
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
backgroundColor: AppColors.primaryColor,
|
color: scheme.outline,
|
||||||
foregroundColor: Colors.white,
|
thickness: 1,
|
||||||
shape: RoundedRectangleBorder(
|
space: 16,
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
),
|
||||||
elevation: 2,
|
|
||||||
extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
// 페이지 트랜지션
|
||||||
extendedTextStyle: const TextStyle(
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
fontSize: 15,
|
builders: {
|
||||||
fontWeight: FontWeight.w600,
|
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
||||||
letterSpacing: 0.1,
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// 스위치 스타일
|
// 스낵바 스타일 (기본 유지)
|
||||||
switchTheme: SwitchThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
|
backgroundColor: scheme.primary,
|
||||||
if (states.contains(WidgetState.selected)) {
|
contentTextStyle: TextStyle(
|
||||||
return AppColors.primaryColor;
|
color: scheme.onPrimary,
|
||||||
}
|
fontSize: 14,
|
||||||
return Colors.white;
|
fontWeight: FontWeight.w500,
|
||||||
}),
|
),
|
||||||
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
|
shape: RoundedRectangleBorder(
|
||||||
if (states.contains(WidgetState.selected)) {
|
borderRadius: BorderRadius.circular(8),
|
||||||
return AppColors.secondaryColor.withValues(alpha: 0.5);
|
),
|
||||||
}
|
behavior: SnackBarBehavior.floating,
|
||||||
return AppColors.borderColor;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 체크박스 스타일
|
|
||||||
checkboxTheme: CheckboxThemeData(
|
|
||||||
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
|
||||||
if (states.contains(WidgetState.selected)) {
|
|
||||||
return AppColors.primaryColor;
|
|
||||||
}
|
|
||||||
return Colors.transparent;
|
|
||||||
}),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
),
|
||||||
side: const BorderSide(color: AppColors.secondaryColor, width: 1.5),
|
);
|
||||||
),
|
})();
|
||||||
|
|
||||||
// 라디오 버튼 스타일
|
|
||||||
radioTheme: RadioThemeData(
|
|
||||||
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
|
||||||
if (states.contains(WidgetState.selected)) {
|
|
||||||
return AppColors.primaryColor;
|
|
||||||
}
|
|
||||||
return AppColors.textSecondary;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 슬라이더 스타일
|
|
||||||
sliderTheme: SliderThemeData(
|
|
||||||
activeTrackColor: AppColors.primaryColor,
|
|
||||||
inactiveTrackColor: AppColors.textSecondary,
|
|
||||||
thumbColor: AppColors.primaryColor,
|
|
||||||
overlayColor: AppColors.primaryColor.withValues(alpha: 0.3),
|
|
||||||
trackHeight: 4,
|
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 탭바 스타일
|
|
||||||
tabBarTheme: const TabBarThemeData(
|
|
||||||
labelColor: AppColors.primaryColor,
|
|
||||||
unselectedLabelColor: AppColors.textSecondary,
|
|
||||||
indicatorColor: AppColors.primaryColor,
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 디바이더 스타일
|
|
||||||
dividerTheme: const DividerThemeData(
|
|
||||||
color: AppColors.dividerColor,
|
|
||||||
thickness: 1,
|
|
||||||
space: 16,
|
|
||||||
),
|
|
||||||
|
|
||||||
// 페이지 트랜지션
|
|
||||||
pageTransitionsTheme: const PageTransitionsTheme(
|
|
||||||
builders: {
|
|
||||||
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
|
||||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
|
||||||
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// 스낵바 스타일
|
|
||||||
snackBarTheme: SnackBarThemeData(
|
|
||||||
backgroundColor: AppColors.textPrimary,
|
|
||||||
contentTextStyle: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
8
lib/theme/color_scheme_ext.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
extension AppColorRoles on ColorScheme {
|
||||||
|
// Semantic roles not present in ColorScheme by default
|
||||||
|
Color get success => const Color(0xFF22C55E); // green 600
|
||||||
|
Color get warning => const Color(0xFFF59E0B); // amber 600
|
||||||
|
Color get info => tertiary; // map info to tertiary
|
||||||
|
}
|
||||||
7
lib/theme/ui_constants.dart
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class UIConstants {
|
||||||
|
static const double pageHorizontalPadding = 16;
|
||||||
|
static const double adVerticalPadding = 12;
|
||||||
|
static const double adCardHeight = 88;
|
||||||
|
static const double cardRadius = 16;
|
||||||
|
static const double cardOutlineAlpha = 0.5; // for outline color alpha
|
||||||
|
}
|
||||||
@@ -32,17 +32,9 @@ class AnimationControllerHelper {
|
|||||||
pulseController.duration = const Duration(milliseconds: 1500);
|
pulseController.duration = const Duration(milliseconds: 1500);
|
||||||
pulseController.repeat(reverse: true);
|
pulseController.repeat(reverse: true);
|
||||||
|
|
||||||
// 웨이브 컨트롤러 초기화
|
// 웨이브 컨트롤러 초기화: 반복으로 부드럽게 루프
|
||||||
waveController.duration = const Duration(milliseconds: 8000);
|
waveController.duration = const Duration(milliseconds: 8000);
|
||||||
waveController.forward();
|
waveController.repeat();
|
||||||
|
|
||||||
// 웨이브 애니메이션이 끝나면 다시 처음부터 부드럽게 시작하도록 설정
|
|
||||||
waveController.addStatusListener((status) {
|
|
||||||
if (status == AnimationStatus.completed) {
|
|
||||||
waveController.reset();
|
|
||||||
waveController.forward();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드
|
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드
|
||||||
|
|||||||
103
lib/utils/billing_date_util.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import 'business_day_util.dart';
|
||||||
|
|
||||||
|
/// 결제 주기 및 결제일 계산 유틸리티
|
||||||
|
class BillingDateUtil {
|
||||||
|
/// 결제 주기를 표준 키로 정규화합니다.
|
||||||
|
/// 반환값 예: 'monthly' | 'quarterly' | 'half-yearly' | 'yearly' | 'weekly'
|
||||||
|
static String normalizeCycle(String cycle) {
|
||||||
|
final c = cycle.trim().toLowerCase();
|
||||||
|
|
||||||
|
// 영어 우선 매핑
|
||||||
|
if (c.contains('monthly')) return 'monthly';
|
||||||
|
if (c.contains('quarter')) return 'quarterly';
|
||||||
|
if (c.contains('half') || c.contains('half-year')) return 'half-yearly';
|
||||||
|
if (c.contains('year')) return 'yearly';
|
||||||
|
if (c.contains('week')) return 'weekly';
|
||||||
|
|
||||||
|
// 한국어
|
||||||
|
if (cycle.contains('매월') || cycle.contains('월간')) return 'monthly';
|
||||||
|
if (cycle.contains('분기')) return 'quarterly';
|
||||||
|
if (cycle.contains('반기')) return 'half-yearly';
|
||||||
|
if (cycle.contains('매년') || cycle.contains('연간')) return 'yearly';
|
||||||
|
if (cycle.contains('주간')) return 'weekly';
|
||||||
|
|
||||||
|
// 일본어
|
||||||
|
if (cycle.contains('毎月')) return 'monthly';
|
||||||
|
if (cycle.contains('四半期')) return 'quarterly';
|
||||||
|
if (cycle.contains('半年')) return 'half-yearly';
|
||||||
|
if (cycle.contains('年間')) return 'yearly';
|
||||||
|
if (cycle.contains('週間')) return 'weekly';
|
||||||
|
|
||||||
|
// 중국어(간체/번체 공통 표현 대응)
|
||||||
|
if (cycle.contains('每月')) return 'monthly';
|
||||||
|
if (cycle.contains('每季度')) return 'quarterly';
|
||||||
|
if (cycle.contains('每半年')) return 'half-yearly';
|
||||||
|
if (cycle.contains('每年')) return 'yearly';
|
||||||
|
if (cycle.contains('每周') || cycle.contains('每週')) return 'weekly';
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
return 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 선택된 날짜가 오늘(또는 과거)이면, 결제 주기에 맞춰 다음 회차 날짜로 보정합니다.
|
||||||
|
/// 이미 미래라면 해당 날짜를 그대로 반환합니다.
|
||||||
|
static DateTime ensureFutureDate(DateTime selected, String cycle) {
|
||||||
|
final normalized = normalizeCycle(cycle);
|
||||||
|
final selectedDateOnly =
|
||||||
|
DateTime(selected.year, selected.month, selected.day);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
|
if (selectedDateOnly.isAfter(today)) return selectedDateOnly;
|
||||||
|
|
||||||
|
DateTime next = selectedDateOnly;
|
||||||
|
switch (normalized) {
|
||||||
|
case 'weekly':
|
||||||
|
while (!next.isAfter(today)) {
|
||||||
|
next = next.add(const Duration(days: 7));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'quarterly':
|
||||||
|
while (!next.isAfter(today)) {
|
||||||
|
next = _addMonthsClamped(next, 3);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'half-yearly':
|
||||||
|
while (!next.isAfter(today)) {
|
||||||
|
next = _addMonthsClamped(next, 6);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'yearly':
|
||||||
|
while (!next.isAfter(today)) {
|
||||||
|
next = _addYearsClamped(next, 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'monthly':
|
||||||
|
default:
|
||||||
|
while (!next.isAfter(today)) {
|
||||||
|
next = _addMonthsClamped(next, 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// month 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다.
|
||||||
|
static DateTime _addMonthsClamped(DateTime base, int months) {
|
||||||
|
final totalMonths = base.month - 1 + months;
|
||||||
|
final year = base.year + totalMonths ~/ 12;
|
||||||
|
final month = totalMonths % 12 + 1;
|
||||||
|
final dim = BusinessDayUtil.daysInMonth(year, month);
|
||||||
|
final day = base.day.clamp(1, dim);
|
||||||
|
return DateTime(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// year 단위 가산 시, 대상 월의 말일을 넘지 않도록 day를 클램프합니다.
|
||||||
|
static DateTime _addYearsClamped(DateTime base, int years) {
|
||||||
|
final year = base.year + years;
|
||||||
|
final dim = BusinessDayUtil.daysInMonth(year, base.month);
|
||||||
|
final day = base.day.clamp(1, dim);
|
||||||
|
return DateTime(year, base.month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/utils/business_day_util.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/// 영업일 계산 유틸리티
|
||||||
|
/// - 주말(토/일)과 일부 고정 공휴일을 제외하고 다음 영업일을 계산합니다.
|
||||||
|
/// - 음력 기반 공휴일(설/추석 등)은 포함하지 않습니다. 필요 시 외부 소스 연동을 고려하세요.
|
||||||
|
class BusinessDayUtil {
|
||||||
|
static bool isWeekend(DateTime date) =>
|
||||||
|
date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
|
||||||
|
|
||||||
|
/// 고정일 한국 공휴일(대체공휴일 미포함)
|
||||||
|
static const List<String> _fixedHolidays = [
|
||||||
|
'01-01', // 신정
|
||||||
|
'03-01', // 삼일절
|
||||||
|
'05-05', // 어린이날
|
||||||
|
'06-06', // 현충일
|
||||||
|
'08-15', // 광복절
|
||||||
|
'10-03', // 개천절
|
||||||
|
'10-09', // 한글날
|
||||||
|
'12-25', // 성탄절
|
||||||
|
];
|
||||||
|
|
||||||
|
static bool isFixedKoreanHoliday(DateTime date) {
|
||||||
|
final key = '${_two(date.month)}-${_two(date.day)}';
|
||||||
|
return _fixedHolidays.contains(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
/// 입력 날짜가 주말/고정 공휴일이면 다음 영업일로 전진합니다.
|
||||||
|
static DateTime nextBusinessDay(DateTime date) {
|
||||||
|
var d = DateTime(date.year, date.month, date.day);
|
||||||
|
while (isWeekend(d) || isFixedKoreanHoliday(d)) {
|
||||||
|
d = d.add(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 대상 월의 말일을 반환합니다.
|
||||||
|
static int daysInMonth(int year, int month) =>
|
||||||
|
DateTime(year, month + 1, 0).day;
|
||||||
|
}
|
||||||
41
lib/utils/payment_card_utils.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
155
lib/utils/subscription_grouping_helper.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
// import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
// import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import '../../controllers/add_subscription_controller.dart';
|
import '../../controllers/add_subscription_controller.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -26,9 +26,11 @@ class AddSubscriptionAppBar extends StatelessWidget
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100));
|
final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100));
|
||||||
|
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: appBarOpacity),
|
// Color adapts to current theme (light/dark)
|
||||||
|
color: scheme.surface.withValues(alpha: appBarOpacity),
|
||||||
boxShadow: appBarOpacity > 0.6
|
boxShadow: appBarOpacity > 0.6
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -43,10 +45,10 @@ class AddSubscriptionAppBar extends StatelessWidget
|
|||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: AppBar(
|
child: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.chevron_left,
|
Icons.chevron_left,
|
||||||
size: 28,
|
size: 28,
|
||||||
color: Color(0xFF1E293B),
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
@@ -57,7 +59,7 @@ class AddSubscriptionAppBar extends StatelessWidget
|
|||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
color: const Color(0xFF1E293B),
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
shadows: appBarOpacity > 0.6
|
shadows: appBarOpacity > 0.6
|
||||||
? [
|
? [
|
||||||
Shadow(
|
Shadow(
|
||||||
@@ -71,33 +73,8 @@ class AddSubscriptionAppBar extends StatelessWidget
|
|||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
actions: [
|
// SMS 스캔 버튼 제거: 우측 액션 비움
|
||||||
if (!kIsWeb)
|
actions: const [],
|
||||||
controller.isLoading
|
|
||||||
? const Padding(
|
|
||||||
padding: EdgeInsets.only(right: 16.0),
|
|
||||||
child: Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Color(0xFF3B82F6)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: IconButton(
|
|
||||||
icon: const FaIcon(
|
|
||||||
FontAwesomeIcons.message,
|
|
||||||
size: 20,
|
|
||||||
color: Color(0xFF3B82F6),
|
|
||||||
),
|
|
||||||
onPressed: onScanSMS,
|
|
||||||
tooltip: AppLocalizations.of(context).scanTextMessages,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../controllers/add_subscription_controller.dart';
|
import '../../controllers/add_subscription_controller.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
import '../../theme/app_colors.dart';
|
// import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 구독 추가 화면의 이벤트/할인 섹션
|
/// 구독 추가 화면의 이벤트/할인 섹션
|
||||||
class AddSubscriptionEventSection extends StatelessWidget {
|
class AddSubscriptionEventSection extends StatelessWidget {
|
||||||
@@ -40,19 +40,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.only(bottom: 20),
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.glassCard,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
color:
|
||||||
|
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.shadowBlack,
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -95,10 +89,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return Text(
|
return Text(
|
||||||
titleText,
|
titleText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -118,7 +112,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
activeColor: controller.gradientColors[0],
|
activeThumbColor: controller.gradientColors[0],
|
||||||
|
activeTrackColor:
|
||||||
|
controller.gradientColors[0].withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -137,18 +133,24 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.infoColor.withValues(alpha: 0.08),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.tertiary
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.infoColor.withValues(alpha: 0.3),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.tertiary
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.info_outline_rounded,
|
Icons.info_outline_rounded,
|
||||||
color: AppColors.infoColor,
|
color: Theme.of(context).colorScheme.tertiary,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -174,9 +176,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return Text(
|
return Text(
|
||||||
infoText,
|
infoText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -272,6 +276,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
label: eventPriceLabel,
|
label: eventPriceLabel,
|
||||||
hintText: eventPriceHint,
|
hintText: eventPriceHint,
|
||||||
|
enabled: controller.isEventActive,
|
||||||
|
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
|
||||||
|
validator:
|
||||||
|
controller.isEventActive ? null : (_) => null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../common/form_fields/base_text_field.dart';
|
import '../common/form_fields/base_text_field.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
import '../common/form_fields/currency_selector.dart';
|
import '../common/form_fields/currency_dropdown_field.dart';
|
||||||
import '../common/form_fields/billing_cycle_selector.dart';
|
import '../common/form_fields/billing_cycle_selector.dart';
|
||||||
import '../common/form_fields/category_selector.dart';
|
import '../common/form_fields/category_selector.dart';
|
||||||
import '../glassmorphism_card.dart';
|
import '../payment_card/payment_card_selector.dart';
|
||||||
import '../../theme/app_colors.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 {
|
class AddSubscriptionForm extends StatelessWidget {
|
||||||
@@ -45,8 +48,15 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
parent: controller.animationController!,
|
parent: controller.animationController!,
|
||||||
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||||
)),
|
)),
|
||||||
child: GlassmorphismCard(
|
child: Card(
|
||||||
backgroundColor: AppColors.glassCard,
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -55,26 +65,19 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
// 헤더
|
// 헤더
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
ShaderMask(
|
Icon(
|
||||||
shaderCallback: (bounds) => LinearGradient(
|
FontAwesomeIcons.fileLines,
|
||||||
colors: controller.gradientColors,
|
size: 20,
|
||||||
begin: Alignment.topLeft,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
end: Alignment.bottomRight,
|
|
||||||
).createShader(bounds),
|
|
||||||
child: const Icon(
|
|
||||||
FontAwesomeIcons.fileLines,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).serviceInfo,
|
AppLocalizations.of(context).serviceInfo,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
color: Color(0xFF1E293B),
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -136,9 +139,8 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
CurrencySelector(
|
CurrencyDropdownField(
|
||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
isGlassmorphism: true,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.currency = value;
|
controller.currency = value;
|
||||||
@@ -158,8 +160,8 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).billingCycle,
|
AppLocalizations.of(context).billingCycle,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -168,7 +170,6 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
BillingCycleSelector(
|
BillingCycleSelector(
|
||||||
billingCycle: controller.billingCycle,
|
billingCycle: controller.billingCycle,
|
||||||
baseColor: controller.gradientColors[0],
|
baseColor: controller.gradientColors[0],
|
||||||
isGlassmorphism: true,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.billingCycle = value;
|
controller.billingCycle = value;
|
||||||
@@ -203,7 +204,7 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
prefixIcon: Icon(
|
prefixIcon: Icon(
|
||||||
Icons.link_rounded,
|
Icons.link_rounded,
|
||||||
color: Colors.grey[600],
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
@@ -226,7 +227,6 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
categories: categoryProvider.categories,
|
categories: categoryProvider.categories,
|
||||||
selectedCategoryId: controller.selectedCategoryId,
|
selectedCategoryId: controller.selectedCategoryId,
|
||||||
baseColor: controller.gradientColors[0],
|
baseColor: controller.gradientColors[0],
|
||||||
isGlassmorphism: true,
|
|
||||||
onChanged: (categoryId) {
|
onChanged: (categoryId) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.selectedCategoryId = categoryId;
|
controller.selectedCategoryId = categoryId;
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -26,19 +26,7 @@ class AddSubscriptionHeader extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
gradient: LinearGradient(
|
color: Theme.of(context).colorScheme.primary,
|
||||||
colors: controller.gradientColors,
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: controller.gradientColors[0].withValues(alpha: 0.3),
|
|
||||||
blurRadius: 20,
|
|
||||||
spreadRadius: 0,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -48,10 +36,10 @@ class AddSubscriptionHeader extends StatelessWidget {
|
|||||||
color: Colors.white.withValues(alpha: 0.2),
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.add_rounded,
|
Icons.add_rounded,
|
||||||
size: 32,
|
size: 32,
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -61,20 +49,23 @@ class AddSubscriptionHeader extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).newSubscriptionAdd,
|
AppLocalizations.of(context).newSubscriptionAdd,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).enterServiceInfo,
|
AppLocalizations.of(context).enterServiceInfo,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.white70,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onPrimary
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AddSubscriptionSaveButton extends StatelessWidget {
|
|||||||
? null
|
? null
|
||||||
: () => controller.saveSubscription(setState: setState),
|
: () => controller.saveSubscription(setState: setState),
|
||||||
isLoading: controller.isLoading,
|
isLoading: controller.isLoading,
|
||||||
backgroundColor: const Color(0xFF3B82F6),
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../models/subscription_model.dart';
|
import '../../models/subscription_model.dart';
|
||||||
import '../../services/currency_util.dart';
|
import '../../services/currency_util.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
import '../../theme/app_colors.dart';
|
// import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
||||||
class AnalysisBadge extends StatelessWidget {
|
class AnalysisBadge extends StatelessWidget {
|
||||||
@@ -26,15 +26,15 @@ class AnalysisBadge extends StatelessWidget {
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.pureWhite,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: borderColor,
|
color: borderColor,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
boxShadow: const [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.shadowBlack,
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
spreadRadius: 2,
|
spreadRadius: 2,
|
||||||
),
|
),
|
||||||
@@ -48,10 +48,10 @@ class AnalysisBadge extends StatelessWidget {
|
|||||||
subscription.serviceName.length > 5
|
subscription.serviceName.length > 5
|
||||||
? '${subscription.serviceName.substring(0, 5)}...'
|
? '${subscription.serviceName.substring(0, 5)}...'
|
||||||
: subscription.serviceName,
|
: subscription.serviceName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 0),
|
const SizedBox(height: 0),
|
||||||
@@ -82,9 +82,9 @@ class AnalysisBadge extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return Text(
|
return Text(
|
||||||
displayText,
|
displayText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 7,
|
fontSize: 7,
|
||||||
color: AppColors.navyGray,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,293 +1,308 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.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 '../../services/currency_util.dart';
|
||||||
import '../../theme/app_colors.dart';
|
// Glass 제거: Material 3 Card 사용
|
||||||
import '../glassmorphism_card.dart';
|
|
||||||
import '../themed_text.dart';
|
import '../themed_text.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../theme/color_scheme_ext.dart';
|
||||||
|
|
||||||
/// 이벤트 할인 현황을 보여주는 카드 위젯
|
/// 이벤트 할인 현황을 보여주는 카드 위젯
|
||||||
class EventAnalysisCard extends StatelessWidget {
|
class EventAnalysisCard extends StatelessWidget {
|
||||||
final AnimationController animationController;
|
final AnimationController animationController;
|
||||||
|
final List<SubscriptionModel> subscriptions;
|
||||||
|
|
||||||
const EventAnalysisCard({
|
const EventAnalysisCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.animationController,
|
required this.animationController,
|
||||||
|
required this.subscriptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return SliverToBoxAdapter(
|
||||||
child: Consumer<SubscriptionProvider>(
|
child: Padding(
|
||||||
builder: (context, provider, child) {
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
return Padding(
|
child: FadeTransition(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
opacity: CurvedAnimation(
|
||||||
child: provider.activeEventSubscriptions.isNotEmpty
|
parent: animationController,
|
||||||
? FadeTransition(
|
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
||||||
opacity: CurvedAnimation(
|
),
|
||||||
parent: animationController,
|
child: SlideTransition(
|
||||||
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
position: Tween<Offset>(
|
||||||
),
|
begin: const Offset(0, 0.2),
|
||||||
child: SlideTransition(
|
end: Offset.zero,
|
||||||
position: Tween<Offset>(
|
).animate(
|
||||||
begin: const Offset(0, 0.2),
|
CurvedAnimation(
|
||||||
end: Offset.zero,
|
parent: animationController,
|
||||||
).animate(CurvedAnimation(
|
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
||||||
parent: animationController,
|
),
|
||||||
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
),
|
||||||
)),
|
child: Card(
|
||||||
child: GlassmorphismCard(
|
elevation: 3,
|
||||||
blur: 10,
|
shape: RoundedRectangleBorder(
|
||||||
opacity: 0.1,
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderRadius: 16,
|
side: BorderSide(
|
||||||
child: Padding(
|
color: Theme.of(context)
|
||||||
padding: const EdgeInsets.all(16),
|
.colorScheme
|
||||||
child: Column(
|
.outline
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
.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: [
|
children: [
|
||||||
Row(
|
FaIcon(
|
||||||
mainAxisAlignment:
|
FontAwesomeIcons.fire,
|
||||||
MainAxisAlignment.spaceBetween,
|
size: 12,
|
||||||
children: [
|
color: Theme.of(context).colorScheme.onError,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(width: 4),
|
||||||
Container(
|
Text(
|
||||||
padding: const EdgeInsets.all(12),
|
AppLocalizations.of(context).servicesInProgress(
|
||||||
decoration: BoxDecoration(
|
activeEventSubscriptions.length),
|
||||||
gradient: LinearGradient(
|
style: TextStyle(
|
||||||
colors: [
|
fontSize: 12,
|
||||||
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,
|
|
||||||
fontWeight: FontWeight.bold,
|
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(height: 16),
|
||||||
: const SizedBox.shrink(),
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/color_scheme_ext.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../services/currency_util.dart';
|
import '../../services/currency_util.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
import '../../theme/app_colors.dart';
|
// Glass 제거: Material 3 Card 사용
|
||||||
import '../glassmorphism_card.dart';
|
|
||||||
import '../themed_text.dart';
|
import '../themed_text.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../utils/reduce_motion.dart';
|
import '../../utils/reduce_motion.dart';
|
||||||
@@ -75,11 +75,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 월간 지출 차트 데이터
|
// 월간 지출 차트 데이터
|
||||||
List<BarChartGroupData> _getMonthlyBarGroups(String locale) {
|
List<BarChartGroupData> _getMonthlyBarGroups(
|
||||||
|
BuildContext context, String locale) {
|
||||||
final List<BarChartGroupData> barGroups = [];
|
final List<BarChartGroupData> barGroups = [];
|
||||||
final calculatedMax = monthlyData.fold<double>(
|
final calculatedMax = monthlyData.fold<double>(
|
||||||
0, (max, data) => math.max(max, data['totalExpense'] as double));
|
0, (max, data) => math.max(max, data['totalExpense'] as double));
|
||||||
final maxAmount = _calculateChartMaxY(calculatedMax, locale);
|
final maxAmount = _calculateChartMaxY(calculatedMax, locale);
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
for (int i = 0; i < monthlyData.length; i++) {
|
for (int i = 0; i < monthlyData.length; i++) {
|
||||||
final data = monthlyData[i];
|
final data = monthlyData[i];
|
||||||
@@ -89,20 +91,13 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
barRods: [
|
barRods: [
|
||||||
BarChartRodData(
|
BarChartRodData(
|
||||||
toY: data['totalExpense'],
|
toY: data['totalExpense'],
|
||||||
gradient: LinearGradient(
|
color: scheme.primary,
|
||||||
colors: [
|
|
||||||
const Color(0xFF3B82F6).withValues(alpha: 0.7),
|
|
||||||
const Color(0xFF60A5FA),
|
|
||||||
],
|
|
||||||
begin: Alignment.bottomCenter,
|
|
||||||
end: Alignment.topCenter,
|
|
||||||
),
|
|
||||||
width: 18,
|
width: 18,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
backDrawRodData: BackgroundBarChartRodData(
|
backDrawRodData: BackgroundBarChartRodData(
|
||||||
show: true,
|
show: true,
|
||||||
toY: maxAmount,
|
toY: maxAmount,
|
||||||
color: AppColors.navyGray.withValues(alpha: 0.1),
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -132,10 +127,17 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
parent: animationController,
|
parent: animationController,
|
||||||
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
|
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
|
||||||
)),
|
)),
|
||||||
child: GlassmorphismCard(
|
child: Card(
|
||||||
blur: 10,
|
elevation: 3,
|
||||||
opacity: 0.1,
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: 16,
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -168,7 +170,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
(max, data) => math.max(
|
(max, data) => math.max(
|
||||||
max, data['totalExpense'] as double)),
|
max, data['totalExpense'] as double)),
|
||||||
locale),
|
locale),
|
||||||
barGroups: _getMonthlyBarGroups(locale),
|
barGroups: _getMonthlyBarGroups(context, locale),
|
||||||
gridData: FlGridData(
|
gridData: FlGridData(
|
||||||
show: true,
|
show: true,
|
||||||
drawVerticalLine: false,
|
drawVerticalLine: false,
|
||||||
@@ -182,8 +184,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
CurrencyUtil.getDefaultCurrency(locale)),
|
CurrencyUtil.getDefaultCurrency(locale)),
|
||||||
getDrawingHorizontalLine: (value) {
|
getDrawingHorizontalLine: (value) {
|
||||||
return FlLine(
|
return FlLine(
|
||||||
color:
|
color: Theme.of(context)
|
||||||
AppColors.navyGray.withValues(alpha: 0.1),
|
.colorScheme
|
||||||
|
.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -222,14 +226,18 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
barTouchData: BarTouchData(
|
barTouchData: BarTouchData(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
touchTooltipData: BarTouchTooltipData(
|
touchTooltipData: BarTouchTooltipData(
|
||||||
tooltipBgColor: AppColors.darkNavy,
|
tooltipBorderRadius: BorderRadius.circular(8),
|
||||||
tooltipRoundedRadius: 8,
|
getTooltipColor: (group) => Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.inverseSurface,
|
||||||
getTooltipItem:
|
getTooltipItem:
|
||||||
(group, groupIndex, rod, rodIndex) {
|
(group, groupIndex, rod, rodIndex) {
|
||||||
return BarTooltipItem(
|
return BarTooltipItem(
|
||||||
'${monthlyData[group.x]['monthName']}\n',
|
'${monthlyData[group.x]['monthName']}\n',
|
||||||
const TextStyle(
|
TextStyle(
|
||||||
color: AppColors.pureWhite,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onInverseSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
@@ -239,8 +247,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
monthlyData[group.x]
|
monthlyData[group.x]
|
||||||
['totalExpense'] as double,
|
['totalExpense'] as double,
|
||||||
locale),
|
locale),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Color(0xFFFBBF24),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.warning,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -251,10 +261,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
swapAnimationDuration: ReduceMotion.isEnabled(context)
|
duration: ReduceMotion.isEnabled(context)
|
||||||
? const Duration(milliseconds: 0)
|
? Duration.zero
|
||||||
: const Duration(milliseconds: 300),
|
: const Duration(milliseconds: 300),
|
||||||
swapAnimationCurve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../models/subscription_model.dart';
|
import '../../models/subscription_model.dart';
|
||||||
import '../../services/currency_util.dart';
|
import '../../services/currency_util.dart';
|
||||||
import '../../services/exchange_rate_service.dart';
|
import '../../services/exchange_rate_service.dart';
|
||||||
import '../../theme/app_colors.dart';
|
// import '../../theme/app_colors.dart';
|
||||||
import '../glassmorphism_card.dart';
|
import '../../theme/color_scheme_ext.dart';
|
||||||
|
// Glass 제거: Material 3 Card 사용
|
||||||
import '../themed_text.dart';
|
import '../themed_text.dart';
|
||||||
import 'analysis_badge.dart';
|
import 'analysis_badge.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -30,18 +31,19 @@ class SubscriptionPieChartCard extends StatefulWidget {
|
|||||||
|
|
||||||
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||||
int _touchedIndex = -1;
|
int _touchedIndex = -1;
|
||||||
late Future<List<PieChartSectionData>> _pieSectionsFuture;
|
// kept for compatibility previously; computation now happens per build
|
||||||
String? _lastLocale;
|
String? _lastLocale;
|
||||||
|
|
||||||
static const _chartColors = [
|
// 차트 팔레트: ColorScheme + 보조 상수(성공/경고/액센트)
|
||||||
Color(0xFF3B82F6),
|
List<Color> _getChartColors(ColorScheme scheme) => [
|
||||||
Color(0xFF10B981),
|
scheme.primary,
|
||||||
Color(0xFFF59E0B),
|
scheme.success,
|
||||||
Color(0xFFEF4444),
|
scheme.warning,
|
||||||
Color(0xFF8B5CF6),
|
scheme.error,
|
||||||
Color(0xFF0EA5E9),
|
scheme.tertiary,
|
||||||
Color(0xFFEC4899),
|
scheme.secondary,
|
||||||
];
|
const Color(0xFFEC4899), // accent
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -62,7 +64,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
|
|
||||||
void _initializeFuture() {
|
void _initializeFuture() {
|
||||||
_lastLocale = context.read<LocaleProvider>().locale.languageCode;
|
_lastLocale = context.read<LocaleProvider>().locale.languageCode;
|
||||||
_pieSectionsFuture = _getPieSections();
|
// no-op: Future computed on demand in build
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
|
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
|
||||||
@@ -85,6 +87,9 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
// 현재 locale 가져오기
|
// 현재 locale 가져오기
|
||||||
final locale = context.read<LocaleProvider>().locale.languageCode;
|
final locale = context.read<LocaleProvider>().locale.languageCode;
|
||||||
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
|
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
|
||||||
|
// Chart palette (capture scheme before any awaits)
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final chartColors = _getChartColors(scheme);
|
||||||
|
|
||||||
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
|
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
|
||||||
List<double> sectionValues = [];
|
List<double> sectionValues = [];
|
||||||
@@ -121,7 +126,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
// 섹션 데이터 생성 (터치 상태 제외)
|
// 섹션 데이터 생성 (터치 상태 제외)
|
||||||
final sections = List.generate(widget.subscriptions.length, (i) {
|
final sections = List.generate(widget.subscriptions.length, (i) {
|
||||||
final percentage = (sectionValues[i] / sectionsTotal) * 100;
|
final percentage = (sectionValues[i] / sectionsTotal) * 100;
|
||||||
final index = i % _chartColors.length;
|
final index = i % chartColors.length;
|
||||||
|
|
||||||
return PieChartSectionData(
|
return PieChartSectionData(
|
||||||
value: sectionValues[i],
|
value: sectionValues[i],
|
||||||
@@ -129,12 +134,12 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
titleStyle: const TextStyle(
|
titleStyle: const TextStyle(
|
||||||
fontSize: 12.0,
|
fontSize: 12.0,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.pureWhite,
|
color: Colors.white,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
color: _chartColors[index],
|
color: chartColors[index],
|
||||||
radius: 100.0,
|
radius: 100.0,
|
||||||
titlePositionPercentageOffset: 0.6,
|
titlePositionPercentageOffset: 0.6,
|
||||||
badgeWidget: null,
|
badgeWidget: null,
|
||||||
@@ -150,12 +155,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
|
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
|
||||||
|
|
||||||
final subscription = widget.subscriptions[index];
|
final subscription = widget.subscriptions[index];
|
||||||
final colorIndex = index % _chartColors.length;
|
final chartColors = _getChartColors(Theme.of(context).colorScheme);
|
||||||
|
final colorIndex = index % chartColors.length;
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: AnalysisBadge(
|
child: AnalysisBadge(
|
||||||
size: 40,
|
size: 40,
|
||||||
borderColor: _chartColors[colorIndex],
|
borderColor: chartColors[colorIndex],
|
||||||
subscription: subscription,
|
subscription: subscription,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -177,7 +183,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
TextStyle(
|
TextStyle(
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.pureWhite,
|
color: Colors.white,
|
||||||
shadows: const [
|
shadows: const [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||||
@@ -210,10 +216,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
parent: widget.animationController,
|
parent: widget.animationController,
|
||||||
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
||||||
)),
|
)),
|
||||||
child: GlassmorphismCard(
|
child: Card(
|
||||||
blur: 10,
|
elevation: 3,
|
||||||
opacity: 0.1,
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: 16,
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -243,20 +256,27 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFE5F2FF),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFFBFDBFE),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context)
|
AppLocalizations.of(context)
|
||||||
.exchangeRateFormat(snapshot.data!),
|
.exchangeRateFormat(snapshot.data!),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF3B82F6),
|
color:
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -291,7 +311,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
: SizedBox(
|
: SizedBox(
|
||||||
height: 250,
|
height: 250,
|
||||||
child: FutureBuilder<List<PieChartSectionData>>(
|
child: FutureBuilder<List<PieChartSectionData>>(
|
||||||
future: _pieSectionsFuture,
|
future: _getPieSections(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState ==
|
if (snapshot.connectionState ==
|
||||||
ConnectionState.waiting) {
|
ConnectionState.waiting) {
|
||||||
@@ -370,12 +390,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
swapAnimationDuration:
|
duration: ReduceMotion.isEnabled(context)
|
||||||
ReduceMotion.isEnabled(context)
|
? Duration.zero
|
||||||
? const Duration(milliseconds: 0)
|
: const Duration(milliseconds: 300),
|
||||||
: const Duration(
|
curve: Curves.easeOut,
|
||||||
milliseconds: 300),
|
|
||||||
swapAnimationCurve: Curves.easeOut,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -392,8 +410,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
(index) {
|
(index) {
|
||||||
final subscription =
|
final subscription =
|
||||||
widget.subscriptions[index];
|
widget.subscriptions[index];
|
||||||
|
final chartColors = _getChartColors(
|
||||||
|
Theme.of(context).colorScheme);
|
||||||
final color =
|
final color =
|
||||||
_chartColors[index % _chartColors.length];
|
chartColors[index % chartColors.length];
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4.0),
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import '../../models/subscription_model.dart';
|
|||||||
import '../../services/currency_util.dart';
|
import '../../services/currency_util.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
import '../../utils/haptic_feedback_helper.dart';
|
import '../../utils/haptic_feedback_helper.dart';
|
||||||
import '../../theme/app_colors.dart';
|
// import '../../theme/app_colors.dart';
|
||||||
import '../glassmorphism_card.dart';
|
import '../../theme/color_scheme_ext.dart';
|
||||||
|
// Glass 제거: Material 3 Card 사용
|
||||||
import '../themed_text.dart';
|
import '../themed_text.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -44,10 +45,17 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
|||||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||||
)),
|
)),
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: GlassmorphismCard(
|
child: Card(
|
||||||
blur: 10,
|
elevation: 3,
|
||||||
opacity: 0.1,
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: 16,
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -85,8 +93,6 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
backgroundColor: AppColors.glassBackground
|
|
||||||
.withValues(alpha: 0.3),
|
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
@@ -142,18 +148,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.glassBackground
|
color: Theme.of(context)
|
||||||
.withValues(alpha: 0.3),
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.glassBorder
|
color: Theme.of(context)
|
||||||
.withValues(alpha: 0.2),
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.listCheck,
|
FontAwesomeIcons.listCheck,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.primaryColor,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -189,18 +201,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.glassBackground
|
color: Theme.of(context)
|
||||||
.withValues(alpha: 0.3),
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.glassBorder
|
color: Theme.of(context)
|
||||||
.withValues(alpha: 0.2),
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.chartLine,
|
FontAwesomeIcons.chartLine,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.successColor,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.success,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -130,9 +130,11 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
transform: Matrix4.identity()
|
transform: Matrix4.identity()
|
||||||
..setEntry(3, 2, 0.001)
|
..setEntry(3, 2, 0.001)
|
||||||
..rotateZ(rotateAnimation.value)
|
..rotateZ(rotateAnimation.value),
|
||||||
..scale(scaleAnimation.value),
|
child: Transform.scale(
|
||||||
child: child,
|
scale: scaleAnimation.value,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -219,7 +221,10 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
FadeTransition(
|
FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.scrim
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 컨테이너 확장 애니메이션
|
// 컨테이너 확장 애니메이션
|
||||||
|
|||||||
@@ -10,15 +10,29 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
final AnimationController pulseController;
|
final AnimationController pulseController;
|
||||||
|
|
||||||
const AnimatedWaveBackground({
|
const AnimatedWaveBackground({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.pulseController,
|
required this.pulseController,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final reduce = ReduceMotion.isEnabled(context);
|
final reduce = ReduceMotion.isEnabled(context);
|
||||||
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
|
final amp = reduce ? 0.3 : 1.0; // 기본 효과 강도 스케일
|
||||||
|
|
||||||
|
// 원 크기에 따라 속도/진폭 스케일을 동적으로 계산
|
||||||
|
// size가 클수록 느리고(차분), 작을수록 빠르고(활발) 크게 움직이게 함
|
||||||
|
MotionParams paramsFor(double size) {
|
||||||
|
const ref = 160.0; // 기준 크기
|
||||||
|
// 진폭 스케일: 0.6 ~ 1.4 사이 (연속)
|
||||||
|
final ampScale = _clamp(ref / size, 0.6, 1.4) * (reduce ? 0.6 : 1.0);
|
||||||
|
// 속도 배수: 1~3의 정수로 제한하여 래핑 시 연속성 보장
|
||||||
|
final raw = 0.8 + (ref / size) * 0.6; // 약 0.8~1.4 범위
|
||||||
|
int speedMult = raw < 1.2 ? 1 : (raw < 1.6 ? 2 : 3);
|
||||||
|
if (reduce && speedMult > 2) speedMult = 2; // 감속 모드 상한
|
||||||
|
return MotionParams(ampScale: ampScale, speedMult: speedMult);
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
||||||
@@ -26,22 +40,26 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
animation: controller,
|
animation: controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
||||||
final angle = controller.value * 2 * math.pi;
|
final p = paramsFor(200);
|
||||||
// 사인 함수를 사용하여 부드러운 움직임 생성
|
final angle = controller.value * 2 * math.pi * p.speedMult;
|
||||||
final xOffset = 20 * amp * math.sin(angle);
|
// 사인 함수를 사용하여 부드러운 움직임 생성 (큰 원: 차분)
|
||||||
final yOffset = 10 * amp * math.cos(angle);
|
final xOffset = 20 * amp * p.ampScale * math.sin(angle);
|
||||||
|
final yOffset = 10 * amp * p.ampScale * math.cos(angle);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: -40 + xOffset,
|
right: -40 + xOffset,
|
||||||
top: -60 + yOffset,
|
top: -60 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
// 회전도 선형적으로 변화하도록 수정
|
// 회전도 선형적으로 변화하도록 수정
|
||||||
angle: 0.2 * amp * math.sin(angle * 0.5),
|
angle: 0.2 * amp * p.ampScale * math.sin(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.1),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -53,21 +71,26 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
animation: controller,
|
animation: controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
||||||
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
final p = paramsFor(220);
|
||||||
final xOffset = 20 * amp * math.cos(angle);
|
final angle =
|
||||||
final yOffset = 10 * amp * math.sin(angle);
|
(controller.value * 2 * math.pi * p.speedMult) + (math.pi / 3);
|
||||||
|
final xOffset = 20 * amp * p.ampScale * math.cos(angle);
|
||||||
|
final yOffset = 10 * amp * p.ampScale * math.sin(angle);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: -80 + xOffset,
|
left: -80 + xOffset,
|
||||||
bottom: -70 + yOffset,
|
bottom: -70 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
// 반대 방향으로 회전하도록 설정
|
// 반대 방향으로 회전하도록 설정
|
||||||
angle: -0.3 * amp * math.sin(angle * 0.5),
|
angle: -0.3 * amp * p.ampScale * math.sin(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 220,
|
width: 220,
|
||||||
height: 220,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.05),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.05),
|
||||||
borderRadius: BorderRadius.circular(110),
|
borderRadius: BorderRadius.circular(110),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -80,20 +103,25 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
animation: controller,
|
animation: controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
||||||
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
final p = paramsFor(120);
|
||||||
final xOffset = 15 * amp * math.sin(angle * 0.7);
|
final angle = (controller.value * 2 * math.pi * p.speedMult) +
|
||||||
final yOffset = 8 * amp * math.cos(angle * 0.7);
|
(math.pi * 2 / 3);
|
||||||
|
final xOffset = 15 * amp * p.ampScale * math.sin(angle * 0.9);
|
||||||
|
final yOffset = 8 * amp * p.ampScale * math.cos(angle * 0.9);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: 40 + xOffset,
|
right: 40 + xOffset,
|
||||||
bottom: -40 + yOffset,
|
bottom: -40 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: 0.4 * amp * math.cos(angle * 0.5),
|
angle: 0.4 * amp * p.ampScale * math.cos(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.08),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.06),
|
||||||
borderRadius: BorderRadius.circular(60),
|
borderRadius: BorderRadius.circular(60),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -112,7 +140,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(
|
color: Theme.of(context).colorScheme.onSurface.withValues(
|
||||||
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
|
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
|
||||||
borderRadius: BorderRadius.circular(15),
|
borderRadius: BorderRadius.circular(15),
|
||||||
),
|
),
|
||||||
@@ -124,3 +152,13 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 내부 유틸리티: 값 범위 제한
|
||||||
|
double _clamp(double v, double min, double max) =>
|
||||||
|
v < min ? min : (v > max ? max : v);
|
||||||
|
|
||||||
|
class MotionParams {
|
||||||
|
final double ampScale;
|
||||||
|
final int speedMult;
|
||||||
|
MotionParams({required this.ampScale, required this.speedMult});
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ class AppNavigator {
|
|||||||
await Navigator.of(context).pushNamed(AppRoutes.settings);
|
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 {
|
static Future<void> toCategoryManagement(BuildContext context) async {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
|||||||
@@ -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(' · ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
|
||||||
|
|
||||||
/// 주요 액션에 사용되는 Primary 버튼
|
/// 주요 액션에 사용되는 Primary 버튼
|
||||||
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
|
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
|
||||||
@@ -44,26 +43,30 @@ class _PrimaryButtonState extends State<PrimaryButton> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final effectiveBackgroundColor =
|
final effectiveBackgroundColor =
|
||||||
widget.backgroundColor ?? theme.primaryColor;
|
widget.backgroundColor ?? theme.colorScheme.primary;
|
||||||
final effectiveForegroundColor =
|
final effectiveForegroundColor =
|
||||||
widget.foregroundColor ?? AppColors.pureWhite;
|
widget.foregroundColor ?? theme.colorScheme.onPrimary;
|
||||||
|
|
||||||
Widget button = AnimatedContainer(
|
Widget button = AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: widget.width ?? double.infinity,
|
width: widget.width ?? double.infinity,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
transform: widget.enableHoverEffect && _isHovered
|
transform: widget.enableHoverEffect && _isHovered
|
||||||
? (Matrix4.identity()..scale(1.02))
|
? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
|
||||||
: Matrix4.identity(),
|
: Matrix4.identity(),
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: widget.isLoading ? null : widget.onPressed,
|
onPressed: widget.isLoading ? null : widget.onPressed,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: effectiveBackgroundColor,
|
backgroundColor: effectiveBackgroundColor,
|
||||||
foregroundColor: effectiveForegroundColor,
|
foregroundColor: effectiveForegroundColor,
|
||||||
|
// 고정 높이와 텍스트 잘림 방지를 위해 최소 사이즈 지정
|
||||||
|
minimumSize: Size.fromHeight(widget.height),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||||
),
|
),
|
||||||
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
|
// 컨테이너에서 높이를 관리하므로 수직 패딩은 0으로 두고
|
||||||
|
// 수평 여백만 부여하여 작은 높이(예: 48)에서 글자 잘림 방지
|
||||||
|
padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 16),
|
||||||
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||||
disabledBackgroundColor:
|
disabledBackgroundColor:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
|
||||||
|
|
||||||
/// 부차적인 액션에 사용되는 Secondary 버튼
|
/// 부차적인 액션에 사용되는 Secondary 버튼
|
||||||
/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다.
|
/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다.
|
||||||
@@ -42,15 +41,17 @@ class _SecondaryButtonState extends State<SecondaryButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
|
final theme = Theme.of(context);
|
||||||
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
|
final effectiveBorderColor =
|
||||||
|
widget.borderColor ?? theme.colorScheme.outline;
|
||||||
|
final effectiveTextColor = widget.textColor ?? theme.colorScheme.primary;
|
||||||
|
|
||||||
Widget button = AnimatedContainer(
|
Widget button = AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: widget.width,
|
width: widget.width,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
transform: widget.enableHoverEffect && _isHovered
|
transform: widget.enableHoverEffect && _isHovered
|
||||||
? (Matrix4.identity()..scale(1.02))
|
? Matrix4.diagonal3Values(1.02, 1.02, 1.0)
|
||||||
: Matrix4.identity(),
|
: Matrix4.identity(),
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: widget.onPressed,
|
onPressed: widget.onPressed,
|
||||||
@@ -70,8 +71,9 @@ class _SecondaryButtonState extends State<SecondaryButton> {
|
|||||||
vertical: 12,
|
vertical: 12,
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
),
|
),
|
||||||
backgroundColor:
|
backgroundColor: _isHovered
|
||||||
_isHovered ? AppColors.glassBackground : Colors.transparent,
|
? theme.colorScheme.onSurface.withValues(alpha: 0.06)
|
||||||
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -140,7 +142,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final effectiveColor = widget.color ?? AppColors.primaryColor;
|
final effectiveColor = widget.color ?? theme.colorScheme.primary;
|
||||||
|
|
||||||
Widget button = AnimatedContainer(
|
Widget button = AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../theme/color_scheme_ext.dart';
|
||||||
|
|
||||||
/// 확인 다이얼로그 위젯
|
/// 확인 다이얼로그 위젯
|
||||||
/// 사용자에게 중요한 작업을 확인받을 때 사용합니다.
|
/// 사용자에게 중요한 작업을 확인받을 때 사용합니다.
|
||||||
@@ -99,7 +100,9 @@ class ConfirmationDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
confirmText,
|
confirmText,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -164,12 +167,13 @@ class SuccessDialog extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.green.withValues(alpha: 0.1),
|
color:
|
||||||
|
Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.check_circle,
|
Icons.check_circle,
|
||||||
color: Colors.green,
|
color: Theme.of(context).colorScheme.success,
|
||||||
size: 64,
|
size: 64,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -188,7 +192,7 @@ class SuccessDialog extends StatelessWidget {
|
|||||||
message!,
|
message!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Colors.grey[600],
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -203,7 +207,7 @@ class SuccessDialog extends StatelessWidget {
|
|||||||
onPressed?.call();
|
onPressed?.call();
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Theme.of(context).colorScheme.success,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -214,8 +218,8 @@ class SuccessDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
buttonText,
|
buttonText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -272,12 +276,12 @@ class ErrorDialog extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red.withValues(alpha: 0.1),
|
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
Icons.error_outline,
|
Icons.error_outline,
|
||||||
color: Colors.red,
|
color: Theme.of(context).colorScheme.error,
|
||||||
size: 64,
|
size: 64,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -296,7 +300,7 @@ class ErrorDialog extends StatelessWidget {
|
|||||||
message!,
|
message!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Colors.grey[600],
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -311,7 +315,7 @@ class ErrorDialog extends StatelessWidget {
|
|||||||
onPressed?.call();
|
onPressed?.call();
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -322,8 +326,8 @@ class ErrorDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
buttonText,
|
buttonText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
// import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 공통 텍스트 필드 위젯
|
/// 공통 텍스트 필드 위젯
|
||||||
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
|
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
|
||||||
@@ -66,10 +66,10 @@ class BaseTextField extends StatelessWidget {
|
|||||||
if (label != null) ...[
|
if (label != null) ...[
|
||||||
Text(
|
Text(
|
||||||
label!,
|
label!,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -89,22 +89,22 @@ class BaseTextField extends StatelessWidget {
|
|||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
minLines: minLines,
|
minLines: minLines,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
cursorColor: cursorColor ?? theme.primaryColor,
|
cursorColor: cursorColor ?? theme.colorScheme.primary,
|
||||||
style: style ??
|
style: style ??
|
||||||
const TextStyle(
|
TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: AppColors.textPrimary,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
hintStyle: const TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: AppColors.textMuted,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
prefixIcon: prefixIcon,
|
prefixIcon: prefixIcon,
|
||||||
prefixText: prefixText,
|
prefixText: prefixText,
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: fillColor ?? AppColors.surfaceColorAlt,
|
fillColor: fillColor ?? Theme.of(context).colorScheme.surface,
|
||||||
contentPadding: contentPadding ?? const EdgeInsets.all(16),
|
contentPadding: contentPadding ?? const EdgeInsets.all(16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -113,15 +113,15 @@ class BaseTextField extends StatelessWidget {
|
|||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: theme.primaryColor,
|
color: theme.colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.borderColor.withValues(alpha: 0.7),
|
color: theme.colorScheme.outline.withValues(alpha: 0.6),
|
||||||
width: 1.5,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
disabledBorder: OutlineInputBorder(
|
disabledBorder: OutlineInputBorder(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
// import '../../../theme/app_colors.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 결제 주기 선택 위젯
|
/// 결제 주기 선택 위젯
|
||||||
@@ -8,8 +8,8 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
final String billingCycle;
|
final String billingCycle;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
final Color? baseColor;
|
final Color? baseColor;
|
||||||
final List<Color>? gradientColors;
|
final List<Color>? gradientColors; // deprecated: ignored
|
||||||
final bool isGlassmorphism;
|
final bool isGlassmorphism; // deprecated: ignored
|
||||||
|
|
||||||
const BillingCycleSelector({
|
const BillingCycleSelector({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -24,19 +24,12 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final localization = AppLocalizations.of(context);
|
final localization = AppLocalizations.of(context);
|
||||||
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
|
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
|
||||||
final cycles = isGlassmorphism
|
final cycles = [
|
||||||
? [
|
localization.monthly,
|
||||||
localization.billingCycleMonthly,
|
localization.billingCycleQuarterly,
|
||||||
localization.billingCycleQuarterly,
|
localization.billingCycleHalfYearly,
|
||||||
localization.billingCycleHalfYearly,
|
localization.yearly,
|
||||||
localization.billingCycleYearly,
|
];
|
||||||
]
|
|
||||||
: [
|
|
||||||
localization.monthly,
|
|
||||||
localization.billingCycleQuarterly,
|
|
||||||
localization.billingCycleHalfYearly,
|
|
||||||
localization.yearly,
|
|
||||||
];
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
@@ -54,16 +47,16 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getBackgroundColor(isSelected),
|
color: _getBackgroundColor(context, isSelected),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: _getBorder(isSelected),
|
border: _getBorder(context, isSelected),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
cycle,
|
cycle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: _getTextColor(isSelected),
|
color: _getTextColor(context, isSelected),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -74,38 +67,22 @@ class BillingCycleSelector extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getBackgroundColor(bool isSelected) {
|
Color _getBackgroundColor(BuildContext context, bool isSelected) {
|
||||||
if (!isSelected) {
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return isGlassmorphism
|
|
||||||
? AppColors.backgroundColor
|
|
||||||
: Colors.grey.withValues(alpha: 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseColor != null) {
|
|
||||||
return baseColor!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gradientColors != null && gradientColors!.isNotEmpty) {
|
|
||||||
return gradientColors![0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return const Color(0xFF3B82F6);
|
|
||||||
}
|
|
||||||
|
|
||||||
Border? _getBorder(bool isSelected) {
|
|
||||||
if (isSelected || !isGlassmorphism) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Border.all(
|
|
||||||
color: AppColors.borderColor.withValues(alpha: 0.5),
|
|
||||||
width: 1.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _getTextColor(bool isSelected) {
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return Colors.white;
|
return baseColor ?? scheme.primary;
|
||||||
}
|
}
|
||||||
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
|
return scheme.surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
Border? _getBorder(BuildContext context, bool isSelected) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
if (isSelected) return null;
|
||||||
|
return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getTextColor(BuildContext context, bool isSelected) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return isSelected ? scheme.onPrimary : scheme.onSurface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
// import '../../../theme/app_colors.dart';
|
||||||
import '../../../providers/category_provider.dart';
|
import '../../../providers/category_provider.dart';
|
||||||
|
|
||||||
/// 카테고리 선택 위젯
|
/// 카테고리 선택 위젯
|
||||||
@@ -10,8 +10,8 @@ class CategorySelector extends StatelessWidget {
|
|||||||
final String? selectedCategoryId;
|
final String? selectedCategoryId;
|
||||||
final ValueChanged<String?> onChanged;
|
final ValueChanged<String?> onChanged;
|
||||||
final Color? baseColor;
|
final Color? baseColor;
|
||||||
final List<Color>? gradientColors;
|
final List<Color>? gradientColors; // deprecated: ignored
|
||||||
final bool isGlassmorphism;
|
final bool isGlassmorphism; // deprecated: ignored
|
||||||
|
|
||||||
const CategorySelector({
|
const CategorySelector({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -39,9 +39,9 @@ class CategorySelector extends StatelessWidget {
|
|||||||
vertical: 10,
|
vertical: 10,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getBackgroundColor(isSelected),
|
color: _getBackgroundColor(context, isSelected),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: _getBorder(isSelected),
|
border: _getBorder(context, isSelected),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -49,7 +49,7 @@ class CategorySelector extends StatelessWidget {
|
|||||||
Icon(
|
Icon(
|
||||||
_getCategoryIcon(category),
|
_getCategoryIcon(category),
|
||||||
size: 18,
|
size: 18,
|
||||||
color: _getTextColor(isSelected),
|
color: _getTextColor(context, isSelected),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Consumer<CategoryProvider>(
|
Consumer<CategoryProvider>(
|
||||||
@@ -60,7 +60,7 @@ class CategorySelector extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: _getTextColor(isSelected),
|
color: _getTextColor(context, isSelected),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -100,38 +100,22 @@ class CategorySelector extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getBackgroundColor(bool isSelected) {
|
Color _getBackgroundColor(BuildContext context, bool isSelected) {
|
||||||
if (!isSelected) {
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return isGlassmorphism
|
|
||||||
? AppColors.backgroundColor
|
|
||||||
: Colors.grey.withValues(alpha: 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseColor != null) {
|
|
||||||
return baseColor!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gradientColors != null && gradientColors!.isNotEmpty) {
|
|
||||||
return gradientColors![0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return const Color(0xFF3B82F6);
|
|
||||||
}
|
|
||||||
|
|
||||||
Border? _getBorder(bool isSelected) {
|
|
||||||
if (isSelected || !isGlassmorphism) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Border.all(
|
|
||||||
color: AppColors.borderColor.withValues(alpha: 0.5),
|
|
||||||
width: 1.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _getTextColor(bool isSelected) {
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return Colors.white;
|
return baseColor ?? scheme.primary;
|
||||||
}
|
}
|
||||||
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
|
return scheme.surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
Border? _getBorder(BuildContext context, bool isSelected) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
if (isSelected) return null;
|
||||||
|
return Border.all(color: scheme.outline.withValues(alpha: 0.6), width: 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getTextColor(BuildContext context, bool isSelected) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return isSelected ? scheme.onPrimary : scheme.onSurface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
lib/widgets/common/form_fields/currency_dropdown_field.dart
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CurrencyDropdownField extends StatelessWidget {
|
||||||
|
final String currency;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
const CurrencyDropdownField({
|
||||||
|
super.key,
|
||||||
|
required this.currency,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
initialValue: currency,
|
||||||
|
isExpanded: true,
|
||||||
|
icon: const Icon(Icons.keyboard_arrow_down_rounded),
|
||||||
|
// 선택된 아이템은 코드만 간결하게 표시하여 오버플로우 방지
|
||||||
|
selectedItemBuilder: (context) {
|
||||||
|
final color = theme.colorScheme.onSurface;
|
||||||
|
return const [
|
||||||
|
'KRW',
|
||||||
|
'USD',
|
||||||
|
'JPY',
|
||||||
|
'CNY',
|
||||||
|
].map((code) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
code,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 14, color: color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: theme.colorScheme.surface,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'KRW', child: _CurrencyItem(symbol: '₩', code: 'KRW')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'USD', child: _CurrencyItem(symbol: '\$', code: 'USD')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'JPY', child: _CurrencyItem(symbol: '¥', code: 'JPY')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'CNY', child: _CurrencyItem(symbol: '¥', code: 'CNY')),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) onChanged(val);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurrencyItem extends StatelessWidget {
|
||||||
|
final String symbol;
|
||||||
|
final String code;
|
||||||
|
|
||||||
|
const _CurrencyItem({required this.symbol, required this.code});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = Theme.of(context).colorScheme.onSurface;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
symbol,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
code,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import 'base_text_field.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 통화 입력 필드 위젯
|
/// 통화 입력 필드 위젯
|
||||||
/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다.
|
/// KRW/JPY(정수), USD/CNY(소수점 2자리)를 지원하며 자동 포맷팅을 제공합니다.
|
||||||
class CurrencyInputField extends StatefulWidget {
|
class CurrencyInputField extends StatefulWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String currency; // 'KRW' or 'USD'
|
final String currency; // 'KRW' | 'USD' | 'JPY' | 'CNY'
|
||||||
final String? label;
|
final String? label;
|
||||||
final String? hintText;
|
final String? hintText;
|
||||||
final Function(double?)? onChanged;
|
final Function(double?)? onChanged;
|
||||||
@@ -39,6 +39,7 @@ class CurrencyInputField extends StatefulWidget {
|
|||||||
class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||||
late FocusNode _focusNode;
|
late FocusNode _focusNode;
|
||||||
bool _isFormatted = false;
|
bool _isFormatted = false;
|
||||||
|
bool _isPostFrameUpdating = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -66,6 +67,29 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant CurrencyInputField oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.currency != widget.currency) {
|
||||||
|
// 통화 변경 시 빌드 이후에 안전하게 재포맷 적용
|
||||||
|
if (_focusNode.hasFocus) return;
|
||||||
|
final value = _parseValue(widget.controller.text);
|
||||||
|
if (value == null) return;
|
||||||
|
final formatted = _formatCurrency(value);
|
||||||
|
if (widget.controller.text == formatted || _isPostFrameUpdating) return;
|
||||||
|
_isPostFrameUpdating = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.controller.value = TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
|
);
|
||||||
|
_isFormatted = true;
|
||||||
|
_isPostFrameUpdating = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onFocusChanged() {
|
void _onFocusChanged() {
|
||||||
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
|
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
|
||||||
// 포커스를 잃었을 때 포맷팅 적용
|
// 포커스를 잃었을 때 포맷팅 적용
|
||||||
@@ -81,7 +105,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
|||||||
final value = _parseValue(widget.controller.text);
|
final value = _parseValue(widget.controller.text);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (widget.currency == 'KRW') {
|
if (_isIntegerCurrency(widget.currency)) {
|
||||||
widget.controller.text = value.toInt().toString();
|
widget.controller.text = value.toInt().toString();
|
||||||
} else {
|
} else {
|
||||||
widget.controller.text = value.toString();
|
widget.controller.text = value.toString();
|
||||||
@@ -97,7 +121,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _formatCurrency(double value) {
|
String _formatCurrency(double value) {
|
||||||
if (widget.currency == 'KRW') {
|
if (_isIntegerCurrency(widget.currency)) {
|
||||||
return NumberFormat.decimalPattern().format(value.toInt());
|
return NumberFormat.decimalPattern().format(value.toInt());
|
||||||
} else {
|
} else {
|
||||||
return NumberFormat('#,##0.00').format(value);
|
return NumberFormat('#,##0.00').format(value);
|
||||||
@@ -108,13 +132,26 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
|||||||
final cleanText = text
|
final cleanText = text
|
||||||
.replaceAll(',', '')
|
.replaceAll(',', '')
|
||||||
.replaceAll('₩', '')
|
.replaceAll('₩', '')
|
||||||
|
.replaceAll('¥', '')
|
||||||
|
.replaceAll('¥', '')
|
||||||
.replaceAll('\$', '')
|
.replaceAll('\$', '')
|
||||||
.trim();
|
.trim();
|
||||||
return double.tryParse(cleanText);
|
return double.tryParse(cleanText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore: unused_element
|
||||||
String get _prefixText {
|
String get _prefixText {
|
||||||
return widget.currency == 'KRW' ? '₩ ' : '\$ ';
|
switch (widget.currency) {
|
||||||
|
case 'KRW':
|
||||||
|
return '₩ ';
|
||||||
|
case 'JPY':
|
||||||
|
return '¥ ';
|
||||||
|
case 'CNY':
|
||||||
|
return '¥ ';
|
||||||
|
case 'USD':
|
||||||
|
default:
|
||||||
|
return '4 ';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getDefaultHintText(BuildContext context) {
|
String _getDefaultHintText(BuildContext context) {
|
||||||
@@ -132,26 +169,27 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
|||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(
|
FilteringTextInputFormatter.allow(
|
||||||
widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')),
|
_isIntegerCurrency(widget.currency)
|
||||||
if (widget.currency == 'USD')
|
? RegExp(r'[0-9]')
|
||||||
// USD의 경우 소수점 이하 2자리까지만 허용
|
: RegExp(r'[0-9.]'),
|
||||||
|
),
|
||||||
|
if (!_isIntegerCurrency(widget.currency))
|
||||||
|
// 소수 통화(USD/CNY): 소수점 이하 2자리 제한
|
||||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||||
final text = newValue.text;
|
final text = newValue.text;
|
||||||
if (text.isEmpty) return newValue;
|
if (text.isEmpty) return newValue;
|
||||||
|
|
||||||
final parts = text.split('.');
|
final parts = text.split('.');
|
||||||
if (parts.length > 2) {
|
if (parts.length > 2) {
|
||||||
// 소수점이 2개 이상인 경우 거부
|
return oldValue; // 소수점이 2개 이상인 경우 거부
|
||||||
return oldValue;
|
|
||||||
}
|
}
|
||||||
if (parts.length == 2 && parts[1].length > 2) {
|
if (parts.length == 2 && parts[1].length > 2) {
|
||||||
// 소수점 이하가 2자리를 초과하는 경우 거부
|
return oldValue; // 소수점 이하 2자 초과 거부
|
||||||
return oldValue;
|
|
||||||
}
|
}
|
||||||
return newValue;
|
return newValue;
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
prefixText: _prefixText,
|
prefixText: _getPrefixText(),
|
||||||
onEditingComplete: widget.onEditingComplete,
|
onEditingComplete: widget.onEditingComplete,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@@ -172,3 +210,23 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isIntegerCurrency(String code) => code == 'KRW' || code == 'JPY';
|
||||||
|
|
||||||
|
// 안전한 프리픽스 계산 함수(모든 통화 지원)
|
||||||
|
String _currencySymbol(String code) {
|
||||||
|
switch (code) {
|
||||||
|
case 'KRW':
|
||||||
|
return '₩';
|
||||||
|
case 'JPY':
|
||||||
|
case 'CNY':
|
||||||
|
return '¥';
|
||||||
|
case 'USD':
|
||||||
|
default:
|
||||||
|
return '\$';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on _CurrencyInputFieldState {
|
||||||
|
String _getPrefixText() => '${_currencySymbol(widget.currency)} ';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
// import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 통화 선택 위젯
|
/// 통화 선택 위젯
|
||||||
/// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다.
|
/// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다.
|
||||||
class CurrencySelector extends StatelessWidget {
|
class CurrencySelector extends StatelessWidget {
|
||||||
final String currency;
|
final String currency;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
final bool isGlassmorphism;
|
final bool isGlassmorphism; // deprecated: ignored
|
||||||
|
|
||||||
const CurrencySelector({
|
const CurrencySelector({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -72,7 +72,7 @@ class _CurrencyOption extends StatelessWidget {
|
|||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool isGlassmorphism;
|
final bool isGlassmorphism; // deprecated: ignored
|
||||||
|
|
||||||
const _CurrencyOption({
|
const _CurrencyOption({
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -96,7 +96,7 @@ class _CurrencyOption extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getBackgroundColor(theme),
|
color: _getBackgroundColor(theme),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: _getBorder(),
|
border: _getBorder(theme),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -107,7 +107,7 @@ class _CurrencyOption extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: _getTextColor(),
|
color: _getTextColor(theme),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
@@ -117,7 +117,7 @@ class _CurrencyOption extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: _getTextColor().withValues(alpha: 0.8),
|
color: _getTextColor(theme).withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -130,28 +130,20 @@ class _CurrencyOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Color _getBackgroundColor(ThemeData theme) {
|
Color _getBackgroundColor(ThemeData theme) {
|
||||||
if (isSelected) {
|
final scheme = theme.colorScheme;
|
||||||
return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6);
|
return isSelected ? scheme.primary : scheme.surface;
|
||||||
}
|
|
||||||
return isGlassmorphism
|
|
||||||
? AppColors.surfaceColorAlt
|
|
||||||
: Colors.grey.withValues(alpha: 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Border? _getBorder() {
|
Border? _getBorder(ThemeData theme) {
|
||||||
if (isSelected || !isGlassmorphism) {
|
if (isSelected) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Border.all(
|
return Border.all(
|
||||||
color: AppColors.borderColor,
|
color: theme.colorScheme.outline.withValues(alpha: 0.6),
|
||||||
width: 1.5,
|
width: 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getTextColor() {
|
Color _getTextColor(ThemeData theme) {
|
||||||
if (isSelected) {
|
final scheme = theme.colorScheme;
|
||||||
return Colors.white;
|
return isSelected ? scheme.onPrimary : scheme.onSurface;
|
||||||
}
|
|
||||||
return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
// import '../../../theme/app_colors.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 날짜 선택 필드 위젯
|
/// 날짜 선택 필드 위젯
|
||||||
@@ -48,10 +48,10 @@ class DatePickerField extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -67,13 +67,14 @@ class DatePickerField extends StatelessWidget {
|
|||||||
lastDate: lastDate ??
|
lastDate: lastDate ??
|
||||||
DateTime.now().add(const Duration(days: 365 * 10)),
|
DateTime.now().add(const Duration(days: 365 * 10)),
|
||||||
builder: (BuildContext context, Widget? child) {
|
builder: (BuildContext context, Widget? child) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: ThemeData.light().copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
colorScheme: ColorScheme.light(
|
colorScheme: cs.copyWith(
|
||||||
primary: effectivePrimaryColor,
|
primary: effectivePrimaryColor,
|
||||||
onPrimary: Colors.white,
|
onPrimary: cs.onPrimary,
|
||||||
surface: Colors.white,
|
surface: cs.surface,
|
||||||
onSurface: Colors.black,
|
onSurface: cs.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: child!,
|
child: child!,
|
||||||
@@ -90,10 +91,13 @@ class DatePickerField extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: contentPadding ?? const EdgeInsets.all(16),
|
padding: contentPadding ?? const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: backgroundColor ?? AppColors.surfaceColorAlt,
|
color: backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.borderColor.withValues(alpha: 0.7),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -105,15 +109,18 @@ class DatePickerField extends StatelessWidget {
|
|||||||
.format(selectedDate),
|
.format(selectedDate),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color:
|
color: enabled
|
||||||
enabled ? AppColors.textPrimary : AppColors.textMuted,
|
? Theme.of(context).colorScheme.onSurface
|
||||||
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.calendar_today,
|
Icons.calendar_today,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: enabled ? AppColors.navyGray : AppColors.textMuted,
|
color: enabled
|
||||||
|
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||||
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -214,13 +221,14 @@ class _DateRangeItem extends StatelessWidget {
|
|||||||
firstDate: firstDate,
|
firstDate: firstDate,
|
||||||
lastDate: lastDate,
|
lastDate: lastDate,
|
||||||
builder: (BuildContext context, Widget? child) {
|
builder: (BuildContext context, Widget? child) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: ThemeData.light().copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
colorScheme: ColorScheme.light(
|
colorScheme: cs.copyWith(
|
||||||
primary: effectivePrimaryColor,
|
primary: effectivePrimaryColor,
|
||||||
onPrimary: Colors.white,
|
onPrimary: cs.onPrimary,
|
||||||
surface: Colors.white,
|
surface: cs.surface,
|
||||||
onSurface: Colors.black,
|
onSurface: cs.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: child!,
|
child: child!,
|
||||||
@@ -237,10 +245,10 @@ class _DateRangeItem extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceColorAlt,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.borderColor.withValues(alpha: 0.7),
|
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.6),
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -249,9 +257,9 @@ class _DateRangeItem extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.textSecondary,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -263,8 +271,9 @@ class _DateRangeItem extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color:
|
color: date != null
|
||||||
date != null ? AppColors.textPrimary : AppColors.textMuted,
|
? Theme.of(context).colorScheme.onSurface
|
||||||
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
33
lib/widgets/common/layout/page_container.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../theme/ui_constants.dart';
|
||||||
|
|
||||||
|
/// 페이지 공통 좌우 패딩과 최대 폭을 보장하는 래퍼
|
||||||
|
class PageContainer extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
final double maxWidth;
|
||||||
|
|
||||||
|
const PageContainer({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding,
|
||||||
|
this.maxWidth = 720,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
|
child: Padding(
|
||||||
|
padding: padding ??
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: UIConstants.pageHorizontalPadding,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/app_colors.dart';
|
import '../../../theme/color_scheme_ext.dart';
|
||||||
|
|
||||||
/// 앱 전체에서 사용되는 통합 스낵바
|
/// 앱 전체에서 사용되는 통합 스낵바
|
||||||
/// 성공, 에러, 정보 등 다양한 타입의 메시지를 표시합니다.
|
/// 성공, 에러, 정보 등 다양한 타입의 메시지를 표시합니다.
|
||||||
@@ -16,9 +16,9 @@ class AppSnackBar {
|
|||||||
context: context,
|
context: context,
|
||||||
message: message,
|
message: message,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
backgroundColor: AppColors.successColor,
|
backgroundColor: Theme.of(context).colorScheme.success,
|
||||||
iconColor: AppColors.pureWhite,
|
iconColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
textColor: AppColors.pureWhite,
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
showAtTop: showAtTop,
|
showAtTop: showAtTop,
|
||||||
);
|
);
|
||||||
@@ -32,13 +32,14 @@ class AppSnackBar {
|
|||||||
Duration duration = const Duration(seconds: 4),
|
Duration duration = const Duration(seconds: 4),
|
||||||
bool showAtTop = true,
|
bool showAtTop = true,
|
||||||
}) {
|
}) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
_show(
|
_show(
|
||||||
context: context,
|
context: context,
|
||||||
message: message,
|
message: message,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
backgroundColor: AppColors.dangerColor,
|
backgroundColor: cs.error,
|
||||||
iconColor: AppColors.pureWhite,
|
iconColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
textColor: AppColors.pureWhite,
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
showAtTop: showAtTop,
|
showAtTop: showAtTop,
|
||||||
);
|
);
|
||||||
@@ -56,9 +57,9 @@ class AppSnackBar {
|
|||||||
context: context,
|
context: context,
|
||||||
message: message,
|
message: message,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
backgroundColor: AppColors.primaryColor,
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
iconColor: AppColors.pureWhite,
|
iconColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
textColor: AppColors.pureWhite,
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
showAtTop: showAtTop,
|
showAtTop: showAtTop,
|
||||||
);
|
);
|
||||||
@@ -76,9 +77,9 @@ class AppSnackBar {
|
|||||||
context: context,
|
context: context,
|
||||||
message: message,
|
message: message,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
backgroundColor: AppColors.warningColor,
|
backgroundColor: Theme.of(context).colorScheme.warning,
|
||||||
iconColor: AppColors.pureWhite,
|
iconColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
textColor: AppColors.pureWhite,
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
showAtTop: showAtTop,
|
showAtTop: showAtTop,
|
||||||
);
|
);
|
||||||
@@ -90,8 +91,8 @@ class AppSnackBar {
|
|||||||
required String message,
|
required String message,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required Color backgroundColor,
|
required Color backgroundColor,
|
||||||
Color iconColor = AppColors.pureWhite,
|
Color iconColor = Colors.white,
|
||||||
Color textColor = AppColors.pureWhite,
|
Color textColor = Colors.white,
|
||||||
Duration duration = const Duration(seconds: 3),
|
Duration duration = const Duration(seconds: 3),
|
||||||
bool showAtTop = true,
|
bool showAtTop = true,
|
||||||
SnackBarAction? action,
|
SnackBarAction? action,
|
||||||
@@ -200,25 +201,25 @@ class AppSnackBar {
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: const EdgeInsets.only(right: 12),
|
margin: const EdgeInsets.only(right: 12),
|
||||||
child: const CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
color: AppColors.pureWhite,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 메시지
|
// 메시지
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
message,
|
message,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.pureWhite,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: AppColors.primaryColor,
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: showAtTop
|
margin: showAtTop
|
||||||
? EdgeInsets.only(
|
? EdgeInsets.only(
|
||||||
@@ -249,7 +250,7 @@ class AppSnackBar {
|
|||||||
required String actionLabel,
|
required String actionLabel,
|
||||||
required VoidCallback onActionPressed,
|
required VoidCallback onActionPressed,
|
||||||
IconData icon = Icons.info_rounded,
|
IconData icon = Icons.info_rounded,
|
||||||
Color backgroundColor = AppColors.primaryColor,
|
Color? backgroundColor,
|
||||||
Duration duration = const Duration(seconds: 4),
|
Duration duration = const Duration(seconds: 4),
|
||||||
bool showAtTop = true,
|
bool showAtTop = true,
|
||||||
}) {
|
}) {
|
||||||
@@ -257,14 +258,14 @@ class AppSnackBar {
|
|||||||
context: context,
|
context: context,
|
||||||
message: message,
|
message: message,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.primary,
|
||||||
iconColor: AppColors.pureWhite,
|
iconColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
textColor: AppColors.pureWhite,
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
showAtTop: showAtTop,
|
showAtTop: showAtTop,
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: actionLabel,
|
label: actionLabel,
|
||||||
textColor: AppColors.pureWhite,
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
onPressed: onActionPressed,
|
onPressed: onActionPressed,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../controllers/detail_screen_controller.dart';
|
import '../../controllers/detail_screen_controller.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/color_scheme_ext.dart';
|
||||||
|
// import '../../theme/app_colors.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -38,19 +39,15 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
)),
|
)),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.glassCard,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.shadowBlack,
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -78,10 +75,10 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).eventPrice,
|
AppLocalizations.of(context).eventPrice,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -98,7 +95,8 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
controller.eventPriceController.clear();
|
controller.eventPriceController.clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
activeColor: baseColor,
|
activeThumbColor: baseColor,
|
||||||
|
activeTrackColor: baseColor.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -109,27 +107,34 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.infoColor.withValues(alpha: 0.08),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.tertiary
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.infoColor.withValues(alpha: 0.3),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.tertiary
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.info_outline_rounded,
|
Icons.info_outline_rounded,
|
||||||
color: AppColors.infoColor,
|
color: Theme.of(context).colorScheme.tertiary,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).eventPriceHint,
|
AppLocalizations.of(context).eventPriceHint,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.darkNavy,
|
color:
|
||||||
|
Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -228,7 +233,7 @@ class _DiscountBadge extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.green.withValues(alpha: 0.1),
|
color: Theme.of(context).colorScheme.success.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -236,15 +241,15 @@ class _DiscountBadge extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.green,
|
color: Theme.of(context).colorScheme.success,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context)
|
AppLocalizations.of(context)
|
||||||
.discountPercent
|
.discountPercent
|
||||||
.replaceAll('@', discountPercentage.toString()),
|
.replaceAll('@', discountPercentage.toString()),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -253,8 +258,8 @@ class _DiscountBadge extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
_getLocalizedDiscountAmount(context, currency, discountAmount),
|
_getLocalizedDiscountAmount(context, currency, discountAmount),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Color(0xFF15803D),
|
color: Theme.of(context).colorScheme.success,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../controllers/detail_screen_controller.dart';
|
import '../../controllers/detail_screen_controller.dart';
|
||||||
import '../../providers/category_provider.dart';
|
import '../../providers/category_provider.dart';
|
||||||
import '../../theme/app_colors.dart';
|
|
||||||
import '../common/form_fields/base_text_field.dart';
|
import '../common/form_fields/base_text_field.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
import '../common/form_fields/currency_selector.dart';
|
// import '../common/form_fields/currency_selector.dart';
|
||||||
|
import '../common/form_fields/currency_dropdown_field.dart';
|
||||||
import '../common/form_fields/billing_cycle_selector.dart';
|
import '../common/form_fields/billing_cycle_selector.dart';
|
||||||
import '../common/form_fields/category_selector.dart';
|
import '../common/form_fields/category_selector.dart';
|
||||||
|
import '../payment_card/payment_card_selector.dart';
|
||||||
|
import '../payment_card/payment_card_form_sheet.dart';
|
||||||
|
import '../../routes/app_routes.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// 상세 화면 폼 섹션
|
/// 상세 화면 폼 섹션
|
||||||
@@ -43,19 +46,15 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
)),
|
)),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.glassCard,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.outline
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppColors.shadowBlack,
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -100,28 +99,18 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).currency,
|
AppLocalizations.of(context).currency,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.darkNavy,
|
color:
|
||||||
|
Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
CurrencySelector(
|
CurrencyDropdownField(
|
||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
isGlassmorphism: true,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.currency = value;
|
controller.currency = value;
|
||||||
// 통화 변경시 금액 포맷 업데이트
|
|
||||||
if (value == 'KRW') {
|
|
||||||
final amount = double.tryParse(controller
|
|
||||||
.monthlyCostController.text
|
|
||||||
.replaceAll(',', ''));
|
|
||||||
if (amount != null) {
|
|
||||||
controller.monthlyCostController.text =
|
|
||||||
amount.toInt().toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -137,17 +126,16 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).billingCycle,
|
AppLocalizations.of(context).billingCycle,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
BillingCycleSelector(
|
BillingCycleSelector(
|
||||||
billingCycle: controller.billingCycle,
|
billingCycle: controller.billingCycle,
|
||||||
baseColor: baseColor,
|
baseColor: baseColor,
|
||||||
isGlassmorphism: true,
|
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.billingCycle = value;
|
controller.billingCycle = value;
|
||||||
},
|
},
|
||||||
@@ -163,7 +151,9 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
controller.nextBillingDate = date;
|
controller.nextBillingDate = date;
|
||||||
},
|
},
|
||||||
label: AppLocalizations.of(context).nextBillingDate,
|
label: AppLocalizations.of(context).nextBillingDate,
|
||||||
firstDate: DateTime.now(),
|
// 과거 결제일을 가진 항목도 수정 가능하도록 범위 완화
|
||||||
|
firstDate: DateTime.now()
|
||||||
|
.subtract(const Duration(days: 365 * 10)),
|
||||||
lastDate:
|
lastDate:
|
||||||
DateTime.now().add(const Duration(days: 365 * 2)),
|
DateTime.now().add(const Duration(days: 365 * 2)),
|
||||||
primaryColor: baseColor,
|
primaryColor: baseColor,
|
||||||
@@ -178,10 +168,10 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context).category,
|
AppLocalizations.of(context).category,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.darkNavy,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -189,7 +179,6 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
categories: categoryProvider.categories,
|
categories: categoryProvider.categories,
|
||||||
selectedCategoryId: controller.selectedCategoryId,
|
selectedCategoryId: controller.selectedCategoryId,
|
||||||
baseColor: baseColor,
|
baseColor: baseColor,
|
||||||
isGlassmorphism: true,
|
|
||||||
onChanged: (categoryId) {
|
onChanged: (categoryId) {
|
||||||
controller.selectedCategoryId = categoryId;
|
controller.selectedCategoryId = categoryId;
|
||||||
},
|
},
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../models/subscription_model.dart';
|
import '../../models/subscription_model.dart';
|
||||||
import '../../controllers/detail_screen_controller.dart';
|
import '../../controllers/detail_screen_controller.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
|
import '../../providers/payment_card_provider.dart';
|
||||||
import '../../services/currency_util.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 '../website_icon.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -30,11 +35,14 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
return Consumer<DetailScreenController>(
|
return Consumer<DetailScreenController>(
|
||||||
builder: (context, controller, child) {
|
builder: (context, controller, child) {
|
||||||
final baseColor = controller.getCardColor();
|
final baseColor = controller.getCardColor();
|
||||||
final gradient = controller.getGradient(baseColor);
|
final paymentCardProvider = context.watch<PaymentCardProvider>();
|
||||||
|
final paymentCard = paymentCardProvider.getCardById(
|
||||||
|
controller.selectedPaymentCardId ?? subscription.paymentCardId,
|
||||||
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 320,
|
constraints: const BoxConstraints(minHeight: 320),
|
||||||
decoration: BoxDecoration(gradient: gradient),
|
decoration: BoxDecoration(color: baseColor),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// 배경 패턴
|
// 배경 패턴
|
||||||
@@ -70,6 +78,7 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 뒤로가기 버튼
|
// 뒤로가기 버튼
|
||||||
@@ -92,7 +101,7 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: 16),
|
||||||
// 서비스 정보
|
// 서비스 정보
|
||||||
FadeTransition(
|
FadeTransition(
|
||||||
opacity: fadeAnimation,
|
opacity: fadeAnimation,
|
||||||
@@ -173,6 +182,11 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
.withValues(alpha: 0.8),
|
.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildPaymentCardChip(
|
||||||
|
context,
|
||||||
|
paymentCard,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -187,42 +201,47 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
_InfoColumn(
|
Expanded(
|
||||||
label: AppLocalizations.of(context)
|
child: _InfoColumn(
|
||||||
.nextBillingDate,
|
label: AppLocalizations.of(context)
|
||||||
value: AppLocalizations.of(context)
|
.nextBillingDate,
|
||||||
.formatDate(
|
value: AppLocalizations.of(context)
|
||||||
controller.nextBillingDate),
|
.formatDate(
|
||||||
|
controller.nextBillingDate),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
FutureBuilder<String>(
|
const SizedBox(width: 12),
|
||||||
future: () async {
|
Expanded(
|
||||||
final locale = context
|
child: FutureBuilder<String>(
|
||||||
.read<LocaleProvider>()
|
future: () async {
|
||||||
.locale
|
final locale = context
|
||||||
.languageCode;
|
.read<LocaleProvider>()
|
||||||
final amount = double.tryParse(
|
.locale
|
||||||
controller
|
.languageCode;
|
||||||
.monthlyCostController.text
|
final amount = double.tryParse(
|
||||||
.replaceAll(',', '')) ??
|
controller
|
||||||
0;
|
.monthlyCostController
|
||||||
return CurrencyUtil
|
.text
|
||||||
.formatAmountWithLocale(
|
.replaceAll(',', '')) ??
|
||||||
amount,
|
0;
|
||||||
controller.currency,
|
return CurrencyUtil
|
||||||
locale,
|
.formatAmountWithLocale(
|
||||||
);
|
amount,
|
||||||
}(),
|
controller.currency,
|
||||||
builder: (context, snapshot) {
|
locale,
|
||||||
return _InfoColumn(
|
);
|
||||||
label: AppLocalizations.of(context)
|
}(),
|
||||||
.monthlyExpense,
|
builder: (context, snapshot) {
|
||||||
value: snapshot.data ?? '-',
|
return _InfoColumn(
|
||||||
alignment: CrossAxisAlignment.end,
|
label: AppLocalizations.of(context)
|
||||||
);
|
.monthlyExpense,
|
||||||
},
|
value: snapshot.data ?? '-',
|
||||||
|
alignment: CrossAxisAlignment.end,
|
||||||
|
wrapValue: true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -269,6 +288,104 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
return cycle;
|
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 label;
|
||||||
final String value;
|
final String value;
|
||||||
final CrossAxisAlignment alignment;
|
final CrossAxisAlignment alignment;
|
||||||
|
final bool wrapValue;
|
||||||
|
|
||||||
const _InfoColumn({
|
const _InfoColumn({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
this.alignment = CrossAxisAlignment.start,
|
this.alignment = CrossAxisAlignment.start,
|
||||||
|
this.wrapValue = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -297,14 +416,27 @@ class _InfoColumn extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
if (wrapValue)
|
||||||
value,
|
Text(
|
||||||
style: const TextStyle(
|
value,
|
||||||
fontSize: 18,
|
textAlign: TextAlign.end,
|
||||||
fontWeight: FontWeight.w700,
|
maxLines: 2,
|
||||||
color: Colors.white,
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||