10 Commits

Author SHA1 Message Date
JiWoong Sul
7ace3afaf3 Merge branch 'codex/fix-notification-reliability'
Some checks failed
Flutter CI / build (push) Has been cancelled
2025-09-19 18:15:36 +09:00
JiWoong Sul
87f82546a4 feat: 알림 재예약 개선과 패키지 업그레이드 2025-09-19 18:10:47 +09:00
JiWoong Sul
e909ba59a4 fix: allow weekend billing dates and restore full-screen alerts 2025-09-19 01:08:09 +09:00
JiWoong Sul
3af9a1f839 fix: ensure notifications use correct channels and dates 2025-09-19 01:06:36 +09:00
JiWoong Sul
44850a53cc feat: adopt material 3 theme and billing adjustments 2025-09-16 14:30:14 +09:00
JiWoong Sul
a01d9092ba docs(pr): summarize notification reliability changes (branch codex/fix-notification-reliability) 2025-09-15 15:38:49 +09:00
JiWoong Sul
3d86316a2b feat(android): add exact alarms permission request entry in Settings\n\n- UI: Settings card shows request when exact alarms not allowed\n- Service: wrap canScheduleExactAlarms/requestExactAlarmsPermission via FLN plugin\n- Keeps changes minimal; no new deps\n\nValidation: scripts/check.sh passed 2025-09-15 15:21:44 +09:00
JiWoong Sul
55e3f67279 fix(notification): improve local notification reliability on iOS/Android\n\n- iOS: set UNUserNotificationCenter delegate and present [.banner,.sound,.badge]\n- Android: create channels on init; use exactAllowWhileIdle; add RECEIVE_BOOT_COMPLETED and SCHEDULE_EXACT_ALARM\n- Dart: ensure iOS present options enabled; fix title variable shadowing\n\nValidation: scripts/check.sh passed (format/analyze/tests)\nRisk: exact alarms require user to allow 'Alarms & reminders' on Android 12+\nRollback: revert manifest perms and switch schedule mode back to inexact 2025-09-15 15:18:45 +09:00
JiWoong Sul
d111b5dd62 fix(sms-permission): re-request on denial and guide permanent denial to app settings
Summary: Improve SMS permission UX so users can request again after denial and are guided to app settings when permanently denied.\nChanges: handle Permission.sms status in controllers, show settings dialog for permanently denied, use kIsWeb guard, context-safety across async.\nValidation: scripts/check.sh passed (analyze/tests OK).\nRisk & Rollback: low; scoped to permission request flow. Revert two controllers if issues.
2025-09-15 11:37:38 +09:00
JiWoong Sul
b944f6967d docs(ads): add AdMob mediation native networks guide with regional strategy and Gradle adapter examples
Summary: Document networks supporting Native ads via AdMob mediation, with regional prioritization, Gradle adapter examples, and setup checklist.\nChanges: adds doc/ads.md.\nValidation: scripts/check.sh passed.\nRisk & Rollback: low; doc-only change. Revert file if needed.
2025-09-15 11:37:32 +09:00
104 changed files with 4770 additions and 3605 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -126,7 +126,7 @@
"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",
"serviceName": "Service Name", "serviceName": "Service Name",
@@ -147,6 +147,7 @@
"estimatedAnnualCost": "Estimated Annual Cost", "estimatedAnnualCost": "Estimated Annual Cost",
"totalSubscriptionServices": "Total Subscription Services", "totalSubscriptionServices": "Total Subscription Services",
"eventDiscountActive": "Event Discount Active", "eventDiscountActive": "Event Discount Active",
"eventDiscountEndsBeforeBilling": "Event discount ends before billing date",
"saving": "Saving", "saving": "Saving",
"paymentDueToday": "Payment Due Today", "paymentDueToday": "Payment Due Today",
"paymentDueInDays": "Payment due in @ days", "paymentDueInDays": "Payment due in @ days",
@@ -199,7 +200,7 @@
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.", "cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
"goToCancelPage": "Go to Cancellation Page", "goToCancelPage": "Go to Cancellation Page",
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name", "urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
"discountPercent": "@% discount", "discountPercent": "% discount",
"discountAmountWon": "Save ₩@", "discountAmountWon": "Save ₩@",
"discountAmountDollar": "Save $@", "discountAmountDollar": "Save $@",
"discountAmountYen": "Save ¥@", "discountAmountYen": "Save ¥@",
@@ -356,7 +357,7 @@
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.", "repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다", "newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기", "findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.", "scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
"startScanning": "스캔 시작하기", "startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다", "foundSubscription": "다음 구독을 찾았습니다",
"serviceName": "서비스명", "serviceName": "서비스명",
@@ -377,6 +378,7 @@
"estimatedAnnualCost": "예상 연간 구독 비용", "estimatedAnnualCost": "예상 연간 구독 비용",
"totalSubscriptionServices": "총 구독 서비스", "totalSubscriptionServices": "총 구독 서비스",
"eventDiscountActive": "이벤트 할인 중", "eventDiscountActive": "이벤트 할인 중",
"eventDiscountEndsBeforeBilling": "이벤트 할인이 결제일 전에 종료됩니다",
"saving": "절약", "saving": "절약",
"paymentDueToday": "오늘 결제 예정", "paymentDueToday": "오늘 결제 예정",
"paymentDueInDays": "@일 후 결제 예정", "paymentDueInDays": "@일 후 결제 예정",
@@ -429,7 +431,7 @@
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.", "cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
"goToCancelPage": "해지 페이지로 이동", "goToCancelPage": "해지 페이지로 이동",
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다", "urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
"discountPercent": "@% 할인", "discountPercent": "% 할인",
"discountAmountWon": "₩@원 절약", "discountAmountWon": "₩@원 절약",
"discountAmountDollar": "$@ 절약", "discountAmountDollar": "$@ 절약",
"discountAmountYen": "¥@ 절약", "discountAmountYen": "¥@ 절약",
@@ -586,7 +588,7 @@
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。", "repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません", "newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索", "findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。サービス名と金額を抽出して簡単にサブスクリプションを追加できます。", "scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
"startScanning": "スキャン開始", "startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました", "foundSubscription": "サブスクリプションが見つかりました",
"serviceName": "サービス名", "serviceName": "サービス名",
@@ -607,6 +609,7 @@
"estimatedAnnualCost": "予想年間サブスクリプション費用", "estimatedAnnualCost": "予想年間サブスクリプション費用",
"totalSubscriptionServices": "総サブスクリプションサービス", "totalSubscriptionServices": "総サブスクリプションサービス",
"eventDiscountActive": "イベント割引中", "eventDiscountActive": "イベント割引中",
"eventDiscountEndsBeforeBilling": "請求日前にイベント割引が終了します",
"saving": "節約", "saving": "節約",
"paymentDueToday": "本日支払い予定", "paymentDueToday": "本日支払い予定",
"paymentDueInDays": "@日後に支払い予定", "paymentDueInDays": "@日後に支払い予定",
@@ -659,7 +662,7 @@
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。", "cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
"goToCancelPage": "解約ページへ移動", "goToCancelPage": "解約ページへ移動",
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます", "urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
"discountPercent": "@%割引", "discountPercent": "%割引",
"discountAmountWon": "₩@節約", "discountAmountWon": "₩@節約",
"discountAmountDollar": "$@節約", "discountAmountDollar": "$@節約",
"discountAmountYen": "¥@節約", "discountAmountYen": "¥@節約",
@@ -805,7 +808,7 @@
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。", "repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
"newSubscriptionNotFound": "未找到新订阅短信", "newSubscriptionNotFound": "未找到新订阅短信",
"findRepeatSubscriptions": "查找支付2次以上的订阅", "findRepeatSubscriptions": "查找支付2次以上的订阅",
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。可以提取服务名称和金额,轻松添加订阅。", "scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额,轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。",
"startScanning": "开始扫描", "startScanning": "开始扫描",
"foundSubscription": "找到订阅", "foundSubscription": "找到订阅",
"serviceName": "服务名称", "serviceName": "服务名称",
@@ -826,6 +829,7 @@
"estimatedAnnualCost": "预计年度订阅费用", "estimatedAnnualCost": "预计年度订阅费用",
"totalSubscriptionServices": "总订阅服务", "totalSubscriptionServices": "总订阅服务",
"eventDiscountActive": "活动折扣中", "eventDiscountActive": "活动折扣中",
"eventDiscountEndsBeforeBilling": "活动折扣将在账单日之前结束",
"saving": "节省", "saving": "节省",
"paymentDueToday": "今日付款到期", "paymentDueToday": "今日付款到期",
"paymentDueInDays": "@天后付款到期", "paymentDueInDays": "@天后付款到期",
@@ -878,7 +882,7 @@
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。", "cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
"goToCancelPage": "前往取消页面", "goToCancelPage": "前往取消页面",
"urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配", "urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配",
"discountPercent": "@%折扣", "discountPercent": "%折扣",
"discountAmountWon": "节省₩@", "discountAmountWon": "节省₩@",
"discountAmountDollar": "节省$@", "discountAmountDollar": "节省$@",
"discountAmountYen": "节省¥@", "discountAmountYen": "节省¥@",

123
doc/ads.md Normal file
View File

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

View File

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

70
doc/plan_color.md Normal file
View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import Flutter import 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])
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ 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 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController { class AddSubscriptionController {
@@ -104,6 +106,26 @@ class AddSubscriptionController {
scrollOffset = scrollController.offset; scrollOffset = scrollController.offset;
}); });
// 언어별 기본 통화 설정
try {
final lang = Localizations.localeOf(context).languageCode;
switch (lang) {
case 'ko':
currency = 'KRW';
break;
case 'ja':
currency = 'JPY';
break;
case 'zh':
currency = 'CNY';
break;
default:
currency = 'USD';
}
} catch (_) {
// Localizations가 아직 준비되지 않은 경우 기본값 유지
}
// 애니메이션 시작 // 애니메이션 시작
animationController!.forward(); animationController!.forward();
} }
@@ -284,25 +306,55 @@ class AddSubscriptionController {
setState(() => isLoading = true); setState(() => isLoading = true);
try { try {
final ctx = context;
if (!await SMSService.hasSMSPermission()) { if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission(); final granted = await SMSService.requestSMSPermission();
if (!ctx.mounted) return;
if (!granted) { if (!granted) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showError( // 영구 거부 여부 확인 후 설정 화면 안내
context: context, final status = await permission.Permission.sms.status;
message: AppLocalizations.of(context).smsPermissionRequired, if (!ctx.mounted) return;
if (status.isPermanentlyDenied) {
await showDialog(
context: ctx,
builder: (_) => AlertDialog(
title: Text(AppLocalizations.of(ctx).smsPermissionRequired),
content:
Text(AppLocalizations.of(ctx).permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(AppLocalizations.of(ctx).cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (ctx.mounted) Navigator.of(ctx).pop();
},
child: Text(AppLocalizations.of(ctx).openSettings),
),
],
),
); );
} else {
AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx).smsPermissionRequired,
);
}
} }
return; return;
} }
} }
final subscriptions = await SMSService.scanSubscriptions(); final subscriptions = await SMSService.scanSubscriptions();
if (!ctx.mounted) return;
if (subscriptions.isEmpty) { if (subscriptions.isEmpty) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showWarning( AppSnackBar.showWarning(
context: context, context: ctx,
message: AppLocalizations.of(context).noSubscriptionSmsFound, message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
); );
} }
return; return;
@@ -434,12 +486,21 @@ class AddSubscriptionController {
double.tryParse(eventPriceController.text.replaceAll(',', '')); double.tryParse(eventPriceController.text.replaceAll(',', ''));
} }
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
final originalDateOnly = DateTime(
nextBillingDate!.year,
nextBillingDate!.month,
nextBillingDate!.day,
);
var adjustedNext =
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
await Provider.of<SubscriptionProvider>(context, listen: false) await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription( .addSubscription(
serviceName: serviceNameController.text.trim(), serviceName: serviceNameController.text.trim(),
monthlyCost: monthlyCost, monthlyCost: monthlyCost,
billingCycle: billingCycle, billingCycle: billingCycle,
nextBillingDate: nextBillingDate!, nextBillingDate: adjustedNext,
websiteUrl: websiteUrlController.text.trim(), websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId, categoryId: selectedCategoryId,
currency: currency, currency: currency,
@@ -449,6 +510,16 @@ class AddSubscriptionController {
eventPrice: eventPrice, eventPrice: eventPrice,
); );
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
}
if (context.mounted) { if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환 Navigator.pop(context, true); // 성공 여부 반환
} }

View File

@@ -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 {
@@ -407,7 +408,12 @@ 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.currency = _currency; subscription.currency = _currency;
@@ -433,6 +439,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 +589,5 @@ class DetailScreenController extends ChangeNotifier {
return colors[hash % colors.length]; return colors[hash % colors.length];
} }
/// 그라데이션 가져오기 // getGradient 제거됨 (그라데이션 미사용)
LinearGradient getGradient(Color baseColor) {
return LinearGradient(
colors: [
baseColor,
baseColor.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
} }

View File

@@ -5,6 +5,8 @@ import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.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:permission_handler/permission_handler.dart' as permission;
import '../utils/logger.dart'; import '../utils/logger.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
@@ -57,6 +59,33 @@ class SmsScanController extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
// Android에서 SMS 권한 확인 및 요청
final ctx = context;
if (!kIsWeb) {
final smsStatus = await permission.Permission.sms.status;
if (!smsStatus.isGranted) {
if (smsStatus.isPermanentlyDenied) {
// 설정 유도 다이얼로그 표시
if (!ctx.mounted) return;
await _showPermissionSettingsDialog(ctx);
_isLoading = false;
notifyListeners();
return;
}
final req = await permission.Permission.sms.request();
if (!ctx.mounted) return;
if (!req.isGranted) {
// 거부됨: 안내 후 종료
if (!ctx.mounted) return;
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired;
_isLoading = false;
notifyListeners();
return;
}
}
}
// SMS 스캔 실행 // SMS 스캔 실행
Log.i('SMS 스캔 시작'); Log.i('SMS 스캔 시작');
final scannedSubscriptionModels = final scannedSubscriptionModels =
@@ -139,6 +168,30 @@ class SmsScanController extends ChangeNotifier {
} }
} }
Future<void> _showPermissionSettingsDialog(BuildContext context) async {
final loc = AppLocalizations.of(context);
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(loc.smsPermissionRequired),
content: Text(loc.permanentlyDeniedMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.cancel),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (context.mounted) Navigator.of(context).pop();
},
child: Text(loc.openSettings),
),
],
),
);
}
Future<void> addCurrentSubscription(BuildContext context) async { Future<void> addCurrentSubscription(BuildContext context) async {
if (_currentIndex >= _scannedSubscriptions.length) return; if (_currentIndex >= _scannedSubscriptions.length) return;

View File

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

View File

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

View File

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

View File

@@ -103,6 +103,14 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
Future<void> _reschedulePaymentNotifications() async {
try {
await NotificationService.reschedulAllNotifications(_subscriptions);
} catch (e) {
debugPrint('결제 알림 재예약 중 오류 발생: $e');
}
}
Future<void> addSubscription({ Future<void> addSubscription({
required String serviceName, required String serviceName,
required double monthlyCost, required double monthlyCost,
@@ -145,6 +153,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 +186,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 +198,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 +227,8 @@ class SubscriptionProvider extends ChangeNotifier {
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
await _reschedulePaymentNotifications();
} }
} }
@@ -226,6 +242,7 @@ class SubscriptionProvider extends ChangeNotifier {
title: '이벤트 종료 알림', title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.', body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
scheduledDate: subscription.eventEndDate!, scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId,
); );
} }
} }

View File

@@ -5,11 +5,11 @@ import '../widgets/add_subscription/add_subscription_header.dart';
import '../widgets/add_subscription/add_subscription_form.dart'; import '../widgets/add_subscription/add_subscription_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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/notification_provider.dart'; import '../providers/notification_provider.dart';
import 'dart:io'; import 'dart:io';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../widgets/glassmorphism_card.dart'; // import '../widgets/glassmorphism_card.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_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 '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
import '../services/sms_service.dart'; import '../services/sms_service.dart';
import '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart';
import '../widgets/common/layout/page_container.dart';
import '../theme/color_scheme_ext.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -29,13 +33,16 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? AppColors.primaryColor.withValues(alpha: 0.2) ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: isSelected color: isSelected
? AppColors.primaryColor ? Theme.of(context).colorScheme.primary
: AppColors.textSecondary.withValues(alpha: 0.5), : Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.5),
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
), ),
), ),
@@ -48,8 +55,8 @@ class SettingsScreen extends StatelessWidget {
? Icons.radio_button_checked ? Icons.radio_button_checked
: Icons.radio_button_unchecked, : Icons.radio_button_unchecked,
color: isSelected color: isSelected
? AppColors.primaryColor ? Theme.of(context).colorScheme.primary
: AppColors.textSecondary, : Theme.of(context).colorScheme.onSurfaceVariant,
size: 24, size: 24,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -59,8 +66,8 @@ class SettingsScreen extends StatelessWidget {
fontSize: 14, fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected color: isSelected
? AppColors.primaryColor ? Theme.of(context).colorScheme.primary
: AppColors.textPrimary, : Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],
@@ -75,109 +82,183 @@ class SettingsScreen extends StatelessWidget {
return Column( return Column(
children: [ children: [
Expanded( Expanded(
child: ListView( child: PageContainer(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [ children: [
// toolbar 높이 추가 // toolbar 높이 추가
SizedBox( SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top, height: kToolbarHeight + MediaQuery.of(context).padding.top,
), ),
// 광고 위젯 추가 // 광고 위젯 추가
const NativeAdWidget(key: ValueKey('settings_ad')), const NativeAdWidget(
key: ValueKey('settings_ad'),
useOuterPadding: true,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 테마 모드 설정
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
final mode = themeProvider.themeMode;
final cs = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context);
Widget chip(AppThemeMode value, String label) {
final selected = mode == value;
return ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) =>
themeProvider.setThemeMode(value),
labelStyle: TextStyle(
color: selected ? cs.onPrimary : cs.onSurface,
fontWeight: FontWeight.w600,
),
selectedColor: cs.primary,
backgroundColor: cs.surface,
side: BorderSide(
color: selected
? cs.primary
: cs.outline.withValues(alpha: 0.6),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.color_lens,
color: cs.onSurfaceVariant),
title: Text(
'테마',
style: TextStyle(color: cs.onSurface),
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
chip(AppThemeMode.system, loc.systemTheme),
chip(AppThemeMode.light, loc.lightTheme),
chip(AppThemeMode.dark, loc.darkTheme),
chip(AppThemeMode.oled, loc.oledTheme),
],
),
],
);
},
),
),
),
// 언어 설정 // 언어 설정
GlassmorphismCard( Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin:
padding: const EdgeInsets.all(8), const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Consumer<LocaleProvider>( child: Consumer<LocaleProvider>(
builder: (context, localeProvider, child) { builder: (context, localeProvider, child) {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
return ListTile( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.language,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
title: Text( title: Text(
loc.language, loc.language,
style: const TextStyle(color: AppColors.textPrimary), style: TextStyle(
),
leading: const Icon(
Icons.language,
color: AppColors.textSecondary,
),
trailing: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: color:
AppColors.textSecondary.withValues(alpha: 0.5), Theme.of(context).colorScheme.onSurface,
),
),
),
DropdownButtonFormField<String>(
initialValue: localeProvider.locale.languageCode,
isExpanded: true,
decoration: InputDecoration(
filled: true,
fillColor:
Theme.of(context).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.of(context)
.colorScheme
.outline
.withValues(alpha: 0.6),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color:
Theme.of(context).colorScheme.primary,
width: 2,
), ),
), ),
child: DropdownButton<String>(
value: localeProvider.locale.languageCode,
underline: const SizedBox(),
borderRadius: BorderRadius.circular(12),
dropdownColor: const Color(0xFF2A2A2A), // 어두운 배경색 설정
icon: const Icon(
Icons.arrow_drop_down,
color: AppColors.textPrimary,
), ),
iconEnabledColor: AppColors.textPrimary,
selectedItemBuilder: (BuildContext context) {
return [
Text(loc.korean,
style: const TextStyle(
color: AppColors.textPrimary)),
Text(loc.english,
style: const TextStyle(
color: AppColors.textPrimary)),
Text(loc.japanese,
style: const TextStyle(
color: AppColors.textPrimary)),
Text(loc.chinese,
style: const TextStyle(
color: AppColors.textPrimary)),
];
},
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'ko', value: 'ko', child: Text(loc.korean)),
child: Text(
loc.korean,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem( DropdownMenuItem(
value: 'en', value: 'en', child: Text(loc.english)),
child: Text(
loc.english,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem( DropdownMenuItem(
value: 'ja', value: 'ja', child: Text(loc.japanese)),
child: Text(
loc.japanese,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem( DropdownMenuItem(
value: 'zh', value: 'zh', child: Text(loc.chinese)),
child: Text( ],
loc.chinese, onChanged: (val) {
style: const TextStyle(color: Colors.white), if (val != null) localeProvider.setLocale(val);
), },
), ),
], ],
onChanged: (String? value) {
if (value != null) {
localeProvider.setLocale(value);
}
},
),
),
); );
}, },
), ),
), ),
),
// 앱 잠금 설정 UI 숨김 // 앱 잠금 설정 UI 숨김
// Card( // Card(
// margin: const EdgeInsets.all(16), // margin: const EdgeInsets.all(16),
@@ -203,57 +284,194 @@ class SettingsScreen extends StatelessWidget {
// ), // ),
// 알림 설정 // 알림 설정
GlassmorphismCard( Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Consumer<NotificationProvider>( child: Consumer<NotificationProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
return Column( return Column(
children: [ children: [
ListTile( // Android 12+ 정확 알람 권한 (알람 및 리마인더)
if (!kIsWeb && Platform.isAndroid)
FutureBuilder<bool>(
future: NotificationService
.canScheduleExactAlarms(),
builder: (context, snap) {
final can = snap.data ?? true;
if (can) return const SizedBox.shrink();
return ListTile(
leading: Icon(Icons.alarm,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
title: Text( title: Text(
AppLocalizations.of(context).notificationPermission, '정확 알람 권한(알람 및 리마인더)',
style: style: TextStyle(
const TextStyle(color: AppColors.textPrimary), color: Theme.of(context)
.colorScheme
.onSurface),
), ),
subtitle: Text( subtitle: Text(
AppLocalizations.of(context) '정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
.notificationPermissionDesc, style: TextStyle(
style: color: Theme.of(context)
const TextStyle(color: AppColors.textSecondary), .colorScheme
.onSurfaceVariant),
), ),
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: () async { onPressed: () async {
final granted = final ok = await NotificationService
await NotificationService.requestPermission(); .requestExactAlarmsPermission();
if (granted) { // 사용자가 설정 화면에서 허용 후 돌아오면 true가 될 수 있음
await provider.setEnabled(true); final recheck =
await NotificationService
.canScheduleExactAlarms();
if (context.mounted) {
if (ok || recheck) {
AppSnackBar.showSuccess(
context: context,
message: '권한이 허용되었습니다.',
);
} else { } else {
if (!context.mounted) return; AppSnackBar.showInfo(
context: context,
message:
'설정에서 "알람 및 리마인더"를 허용해 주세요.',
);
}
(context as Element).markNeedsBuild();
}
},
child: const Text('허용 요청'),
),
);
},
),
FutureBuilder<permission.PermissionStatus>(
future: permission.Permission.notification.status,
builder: (context, snapshot) {
final isLoading = snapshot.connectionState ==
ConnectionState.waiting;
final status = snapshot.data;
final hasPermission =
status?.isGranted ?? false;
final isPermanentlyDenied =
status?.isPermanentlyDenied ?? false;
return ListTile(
title: Text(
AppLocalizations.of(context)
.notificationPermission,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: !hasPermission
? Text(
isPermanentlyDenied
? AppLocalizations.of(context)
.permanentlyDeniedMessage
: AppLocalizations.of(context)
.notificationPermissionDesc,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
)
: null,
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: hasPermission
? Padding(
padding:
const EdgeInsets.symmetric(
horizontal: 8.0),
child: Icon(
Icons.check_circle,
color: Theme.of(context)
.colorScheme
.success,
),
)
: isPermanentlyDenied
? TextButton(
onPressed: () async {
await permission
.openAppSettings();
},
child: Text(
AppLocalizations.of(
context)
.openSettings),
)
: ElevatedButton(
onPressed: () async {
final granted =
await NotificationService
.requestPermission();
if (granted) {
await provider
.setEnabled(true);
} else {
if (!context.mounted) {
return;
}
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context) message: AppLocalizations
.of(context)
.notificationPermissionDenied, .notificationPermissionDenied,
); );
} }
if (context.mounted) {
(context as Element)
.markNeedsBuild();
}
}, },
child: Text( child: Text(
AppLocalizations.of(context).requestPermission), AppLocalizations.of(
context)
.requestPermission),
), ),
);
},
), ),
const Divider(), const Divider(),
// 결제 예정 알림 기본 스위치 // 결제 예정 알림 기본 스위치
SwitchListTile( SwitchListTile(
title: Text( title: Text(
AppLocalizations.of(context).paymentNotification, AppLocalizations.of(context)
style: .paymentNotification,
const TextStyle(color: AppColors.textPrimary), style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
), ),
subtitle: Text( subtitle: Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.paymentNotificationDesc, .paymentNotificationDesc,
style: style: TextStyle(
const TextStyle(color: AppColors.textSecondary), color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
), ),
value: provider.isPaymentEnabled, value: provider.isPaymentEnabled,
onChanged: (value) { onChanged: (value) {
@@ -276,7 +494,14 @@ class SettingsScreen extends StatelessWidget {
.surfaceContainerHighest .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius:
BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -289,32 +514,38 @@ class SettingsScreen extends StatelessWidget {
AppLocalizations.of(context) AppLocalizations.of(context)
.notificationTiming, .notificationTiming,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold)), fontWeight:
FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding:
const EdgeInsets.symmetric(
vertical: 8.0), vertical: 8.0),
child: Row( child: Row(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.spaceEvenly, MainAxisAlignment
.spaceEvenly,
children: [ children: [
_buildReminderDayRadio( _buildReminderDayRadio(
context, context,
provider, provider,
1, 1,
AppLocalizations.of(context) AppLocalizations.of(
context)
.oneDayBefore), .oneDayBefore),
_buildReminderDayRadio( _buildReminderDayRadio(
context, context,
provider, provider,
2, 2,
AppLocalizations.of(context) AppLocalizations.of(
context)
.twoDaysBefore), .twoDaysBefore),
_buildReminderDayRadio( _buildReminderDayRadio(
context, context,
provider, provider,
3, 3,
AppLocalizations.of(context) AppLocalizations.of(
context)
.threeDaysBefore), .threeDaysBefore),
], ],
), ),
@@ -327,7 +558,8 @@ class SettingsScreen extends StatelessWidget {
AppLocalizations.of(context) AppLocalizations.of(context)
.notificationTime, .notificationTime,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold)), fontWeight:
FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
InkWell( InkWell(
onTap: () async { onTap: () async {
@@ -335,18 +567,20 @@ class SettingsScreen extends StatelessWidget {
await showTimePicker( await showTimePicker(
context: context, context: context,
initialTime: TimeOfDay( initialTime: TimeOfDay(
hour: provider.reminderHour, hour: provider
.reminderHour,
minute: provider minute: provider
.reminderMinute), .reminderMinute),
); );
if (picked != null) { if (picked != null) {
provider.setReminderTime( provider.setReminderTime(
picked.hour, picked.minute); picked.hour,
picked.minute);
} }
}, },
child: Container( child: Container(
padding: padding: const EdgeInsets
const EdgeInsets.symmetric( .symmetric(
vertical: 12, vertical: 12,
horizontal: 16), horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -354,10 +588,12 @@ class SettingsScreen extends StatelessWidget {
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.outline .outline
.withValues(alpha: 0.5), .withValues(
alpha: 0.5),
), ),
borderRadius: borderRadius:
BorderRadius.circular(8), BorderRadius.circular(
8),
), ),
child: Row( child: Row(
children: [ children: [
@@ -366,8 +602,8 @@ class SettingsScreen extends StatelessWidget {
children: [ children: [
Icon( Icon(
Icons.access_time, Icons.access_time,
color: color: Theme.of(
Theme.of(context) context)
.colorScheme .colorScheme
.primary, .primary,
size: 22, size: 22,
@@ -379,7 +615,8 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: fontWeight:
FontWeight.bold, FontWeight
.bold,
color: Theme.of( color: Theme.of(
context) context)
.colorScheme .colorScheme
@@ -404,20 +641,23 @@ class SettingsScreen extends StatelessWidget {
// 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화) // 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화)
if (provider.reminderDays >= 2) if (provider.reminderDays >= 2)
Padding( Padding(
padding: const EdgeInsets.only( padding:
const EdgeInsets.only(
top: 16.0), top: 16.0),
child: Container( child: Container(
padding: padding: const EdgeInsets
const EdgeInsets.symmetric( .symmetric(
vertical: 4, vertical: 4,
horizontal: 4), horizontal: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceContainerHighest .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(
alpha: 0.3),
borderRadius: borderRadius:
BorderRadius.circular(8), BorderRadius.circular(
8),
), ),
child: SwitchListTile( child: SwitchListTile(
contentPadding: contentPadding:
@@ -430,23 +670,22 @@ class SettingsScreen extends StatelessWidget {
.dailyReminder), .dailyReminder),
subtitle: Text( subtitle: Text(
provider.isDailyReminderEnabled provider.isDailyReminderEnabled
? AppLocalizations.of( ? AppLocalizations
context) .of(context)
.dailyReminderEnabled .dailyReminderEnabled
: AppLocalizations.of( : AppLocalizations
context) .of(context)
.dailyReminderDisabledWithDays( .dailyReminderDisabledWithDays(
provider provider
.reminderDays), .reminderDays),
style: const TextStyle( style: TextStyle(
color: AppColors color: Theme.of(
.textLight), context)
.colorScheme
.onSurfaceVariant),
), ),
value: provider value: provider
.isDailyReminderEnabled, .isDailyReminderEnabled,
activeColor: Theme.of(context)
.colorScheme
.primary,
onChanged: (value) { onChanged: (value) {
provider provider
.setDailyReminderEnabled( .setDailyReminderEnabled(
@@ -455,6 +694,25 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
), ),
if (kDebugMode)
Padding(
padding:
const EdgeInsets.only(
top: 16.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(Icons
.notifications_active),
label:
const Text('테스트 알림'),
onPressed: () {
NotificationService
.showTestPaymentNotification();
},
),
),
),
], ],
), ),
), ),
@@ -462,6 +720,8 @@ class SettingsScreen extends StatelessWidget {
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
// 디버그 전용: 결제 알림 테스트 버튼 (숨김)
// 미사용 서비스 알림 기능 비활성화 // 미사용 서비스 알림 기능 비활성화
// const Divider(), // const Divider(),
// SwitchListTile( // SwitchListTile(
@@ -477,13 +737,16 @@ class SettingsScreen extends StatelessWidget {
}, },
), ),
), ),
),
// SMS 권한 설정 // SMS 권한 설정
if (!kIsWeb && Platform.isAndroid) if (!kIsWeb && Platform.isAndroid)
GlassmorphismCard( Card(
margin: margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8), const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8), elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: FutureBuilder<permission.PermissionStatus>( child: FutureBuilder<permission.PermissionStatus>(
future: permission.Permission.sms.status, future: permission.Permission.sms.status,
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -495,13 +758,16 @@ class SettingsScreen extends StatelessWidget {
status?.isPermanentlyDenied ?? false; status?.isPermanentlyDenied ?? false;
return ListTile( return ListTile(
leading: const Icon( contentPadding: const EdgeInsets.all(8),
leading: Icon(
Icons.sms, Icons.sms,
color: AppColors.textSecondary, color:
Theme.of(context).colorScheme.onSurfaceVariant,
), ),
title: Text( title: Text(
AppLocalizations.of(context).smsPermissionLabel, AppLocalizations.of(context).smsPermissionLabel,
style: const TextStyle(color: AppColors.textPrimary), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
), ),
subtitle: !hasPermission subtitle: !hasPermission
? Text( ? Text(
@@ -510,8 +776,10 @@ class SettingsScreen extends StatelessWidget {
.permanentlyDeniedMessage .permanentlyDeniedMessage
: AppLocalizations.of(context) : AppLocalizations.of(context)
.smsPermissionRequired, .smsPermissionRequired,
style: const TextStyle( style: TextStyle(
color: AppColors.textSecondary), color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
) )
: null, : null,
trailing: isLoading trailing: isLoading
@@ -522,18 +790,21 @@ class SettingsScreen extends StatelessWidget {
CircularProgressIndicator(strokeWidth: 2), CircularProgressIndicator(strokeWidth: 2),
) )
: hasPermission : hasPermission
? const Padding( ? Padding(
padding: padding: const EdgeInsets.symmetric(
EdgeInsets.symmetric(horizontal: 8.0), horizontal: 8.0),
child: Icon(Icons.check_circle, child: Icon(Icons.check_circle,
color: Colors.green), color: Theme.of(context)
.colorScheme
.success),
) )
: isPermanentlyDenied : isPermanentlyDenied
? TextButton( ? TextButton(
onPressed: () async { onPressed: () async {
await permission.openAppSettings(); await permission.openAppSettings();
}, },
child: Text(AppLocalizations.of(context) child: Text(
AppLocalizations.of(context)
.openSettings), .openSettings),
) )
: ElevatedButton( : ElevatedButton(
@@ -543,7 +814,8 @@ class SettingsScreen extends StatelessWidget {
if (!granted) { if (!granted) {
final newStatus = await permission final newStatus = await permission
.Permission.sms.status; .Permission.sms.status;
if (newStatus.isPermanentlyDenied) { if (newStatus
.isPermanentlyDenied) {
await permission await permission
.openAppSettings(); .openAppSettings();
} }
@@ -553,7 +825,8 @@ class SettingsScreen extends StatelessWidget {
.markNeedsBuild(); .markNeedsBuild();
} }
}, },
child: Text(AppLocalizations.of(context) child: Text(
AppLocalizations.of(context)
.requestPermission), .requestPermission),
), ),
); );
@@ -562,25 +835,36 @@ class SettingsScreen extends StatelessWidget {
), ),
// 앱 정보 // 앱 정보
GlassmorphismCard( Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin:
padding: const EdgeInsets.all(8), const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.all(8),
title: Text( title: Text(
AppLocalizations.of(context).appInfo, AppLocalizations.of(context).appInfo,
style: const TextStyle(color: AppColors.textPrimary), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
'${AppLocalizations.of(context).version} 1.0.0', '${AppLocalizations.of(context).version} 1.0.0',
style: const TextStyle(color: AppColors.textSecondary), style: TextStyle(
), color:
leading: const Icon( Theme.of(context).colorScheme.onSurfaceVariant),
Icons.info,
color: AppColors.textSecondary,
), ),
leading: Icon(Icons.info,
color: Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () async { onTap: () async {
// 웹 환경에서는 기본 다이얼로그 표시 // 항상 앱 내 About 다이얼로그를 우선 표시
if (kIsWeb) {
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: AppLocalizations.of(context).appTitle, applicationName: AppLocalizations.of(context).appTitle,
@@ -591,54 +875,47 @@ class SettingsScreen extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${AppLocalizations.of(context).developer}: Julian Sul'), '${AppLocalizations.of(context).developer}: Julian Sul'),
], const SizedBox(height: 12),
); Builder(builder: (ctx) {
return; return TextButton.icon(
} icon: const Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(ctx).openStore),
// 앱 스토어 링크 onPressed: () async {
String storeUrl = '';
// 플랫폼에 따라 스토어 링크 설정
if (Platform.isAndroid) {
// Android - Google Play 스토어 링크
storeUrl =
'https://play.google.com/store/apps/details?id=com.submanager.app';
} else if (Platform.isIOS) {
// iOS - App Store 링크
storeUrl =
'https://apps.apple.com/app/submanager/id123456789';
}
if (storeUrl.isNotEmpty) {
try { try {
final Uri url = Uri.parse(storeUrl); if (Platform.isAndroid) {
await launchUrl(url, // 우선 Play 스토어 앱 시도
const pkg =
'com.naturebridgeai.digitalrentmanager';
final marketUri =
Uri.parse('market://details?id=$pkg');
final webUri = Uri.parse(
'https://play.google.com/store/apps/details?id=$pkg');
final ok = await launchUrl(marketUri,
mode: LaunchMode.externalApplication); mode: LaunchMode.externalApplication);
if (!ok) {
await launchUrl(webUri,
mode: LaunchMode.externalApplication);
}
} else if (Platform.isIOS) {
final uri = Uri.parse(
'https://apps.apple.com/app/id123456789');
await launchUrl(uri,
mode: LaunchMode.externalApplication);
}
} catch (e) { } catch (e) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: ctx,
message: message: AppLocalizations.of(ctx)
AppLocalizations.of(context).cannotOpenStore, .cannotOpenStore,
); );
} }
} }
} else { },
// 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시 );
showAboutDialog( }),
context: context,
applicationName: AppLocalizations.of(context).appTitle,
applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50),
children: [
Text(AppLocalizations.of(context).appDescription),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context).developer}: Julian Sul'),
], ],
); );
}
}, },
), ),
), ),
@@ -649,6 +926,7 @@ class SettingsScreen extends StatelessWidget {
], ],
), ),
), ),
),
], ],
); );
} }

View File

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

View File

@@ -16,18 +16,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();
} }
@@ -93,16 +96,37 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
websiteUrlController: _controller.websiteUrlController, websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId, selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId, onCategoryChanged: _controller.setSelectedCategoryId,
onAdd: () => _controller.addCurrentSubscription(context), onAdd: _handleAddSubscription,
onSkip: () => _controller.skipCurrentSubscription(context), onSkip: _handleSkipSubscription,
), ),
], ],
); );
} }
Future<void> _handleAddSubscription() async {
await _controller.addCurrentSubscription(context);
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
void _handleSkipSubscription() {
_controller.skipCurrentSubscription(context);
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToTop());
}
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: [

View File

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

View File

@@ -5,6 +5,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';
final notificationDetails = NotificationDetails(
android: AndroidNotificationDetails(
_paymentChannelId,
title,
channelDescription: title,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
); autoCancel: false,
),
const iosDetails = DarwinNotificationDetails( iOS: const DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
presentBadge: true, presentBadge: true,
presentSound: true, presentSound: true,
); ),
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
); );
// 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: DarwinNotificationDetails(), iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
), ),
uiLocalNotificationDateInterpretation: ),
UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: scheduleMode,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, payload: _paymentPayload(subscription.id),
); );
// 매일 반복 알림 설정 (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: DarwinNotificationDetails(), iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
), ),
uiLocalNotificationDateInterpretation: ),
UILocalNotificationDateInterpretation.absoluteTime, androidScheduleMode: scheduleMode,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, payload: _paymentPayload(subscription.id),
); );
} }
} }
@@ -629,7 +844,109 @@ class NotificationService {
} }
} }
// 디버그 테스트용: 즉시 결제 알림을 보여줍니다.
static Future<void> showTestPaymentNotification() async {
if (_isWeb || !_initialized) return;
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
final amountText =
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
final body = '테스트 구독 • $amountText';
await _notifications.show(
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
'subscription_channel',
title,
channelDescription: title,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
);
} catch (e) {
debugPrint('테스트 결제 알림 표시 실패: $e');
}
}
static String getNotificationBody(String serviceName, double amount) { 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)';
}
}
} }

View File

@@ -5,6 +5,7 @@ import '../utils/logger.dart';
import '../temp/test_sms_data.dart'; import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
class SmsScanner { class SmsScanner {
final SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
@@ -56,7 +57,61 @@ class SmsScanner {
// 2회 이상 반복된 서비스만 구독으로 간주 // 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 2) { if (entry.value.length >= 2) {
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 // 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용
final messages = [...entry.value];
messages.sort((a, b) {
final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ??
DateTime(1970);
final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ??
DateTime(1970);
return db.compareTo(da); // desc
});
final mostRecent = messages.first;
DateTime? recentDate =
DateTime.tryParse(mostRecent['previousPaymentDate'] ?? '');
DateTime? prevDate = messages.length > 1
? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '')
: null;
// 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자
int baseDay = recentDate?.day ?? DateTime.now().day;
// 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정
if (recentDate != null && prevDate != null) {
final candidate = DateTime(prevDate.year, prevDate.month, baseDay);
if (BusinessDayUtil.isWeekend(candidate)) {
final diff = prevDate.difference(candidate).inDays;
if (diff >= 1 && diff <= 3) {
// 예: 12일(토)→14일(월)
baseDay = baseDay; // 유지
} else {
// 차이가 크면 이전 달의 일자를 채택
baseDay = prevDate.day;
}
}
}
// 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정
final DateTime now = DateTime.now();
int year = now.year;
int month = now.month;
if (now.day >= baseDay) {
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
final dim = BusinessDayUtil.daysInMonth(year, month);
final day = baseDay.clamp(1, dim);
DateTime nextBilling = DateTime(year, month, day);
nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling);
// 가장 최근 SMS 맵에 override 값으로 주입
final serviceSms = Map<String, dynamic>.from(mostRecent);
serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String();
final subscription = _parseSms(serviceSms, entry.value.length); final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) { if (subscription != null) {
Log.i( Log.i(
@@ -134,7 +189,11 @@ class SmsScanner {
} }
DateTime? nextBillingDate; DateTime? nextBillingDate;
if (nextBillingDateStr != null) { // 외부에서 계산된 다음 결제일이 있으면 우선 사용
final overrideNext = sms['overrideNextBillingDate'] as String?;
if (overrideNext != null) {
nextBillingDate = DateTime.tryParse(overrideNext);
} else if (nextBillingDateStr != null) {
nextBillingDate = DateTime.tryParse(nextBillingDateStr); nextBillingDate = DateTime.tryParse(nextBillingDateStr);
} }
@@ -147,7 +206,11 @@ class SmsScanner {
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정 // 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
DateTime adjustedNextBillingDate = _calculateNextBillingDate( DateTime adjustedNextBillingDate = _calculateNextBillingDate(
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
billingCycle); billingCycle,
);
// 주말/공휴일 보정
adjustedNextBillingDate =
BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate);
return SubscriptionModel( return SubscriptionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -190,7 +253,9 @@ class SmsScanner {
} }
} }
return DateTime(year, month, billingDate.day); final dim = BusinessDayUtil.daysInMonth(year, month);
final day = billingDate.day.clamp(1, dim);
return DateTime(year, month, day);
} else if (billingCycle == 'yearly') { } else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인 // 올해의 결제일이 지났는지 확인
final thisYearBilling = final thisYearBilling =

View File

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

View File

@@ -10,114 +10,119 @@ class AdaptiveTheme {
/// 다크 테마 /// 다크 테마
static ThemeData get darkTheme { static ThemeData get darkTheme {
return ThemeData( const scheme = ColorScheme.dark(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark(
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: Color(0xFF1E1E1E), surface: Color(0xFF1E1E1E),
), );
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: scheme,
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( textTheme: ThemeData.dark(useMaterial3: true)
color: Colors.white.withValues(alpha: 0.9), .textTheme
size: 24, .copyWith(
),
),
textTheme: TextTheme(
headlineLarge: const TextStyle( headlineLarge: const TextStyle(
color: Colors.white,
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineMedium: const TextStyle( headlineMedium: const TextStyle(
color: Colors.white,
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineSmall: const TextStyle( headlineSmall: const TextStyle(
color: Colors.white,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.25, letterSpacing: -0.25,
height: 1.3, height: 1.3,
), ),
titleLarge: const TextStyle( titleLarge: const TextStyle(
color: Colors.white,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
height: 1.4, height: 1.4,
), ),
titleMedium: const TextStyle( titleMedium: const TextStyle(
color: Colors.white,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.1, letterSpacing: -0.1,
height: 1.4, height: 1.4,
), ),
titleSmall: const TextStyle( titleSmall: const TextStyle(
color: Colors.white,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4, height: 1.4,
), ),
bodyLarge: TextStyle( bodyLarge: const TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.5, height: 1.5,
), ),
bodyMedium: TextStyle( bodyMedium: const TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.5, height: 1.5,
), ),
bodySmall: TextStyle( bodySmall: const TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.2, letterSpacing: 0.2,
height: 1.5, height: 1.5,
), ),
labelLarge: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
), ),
labelMedium: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
height: 1.4,
),
)
.apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: const Color(0xFF2A2A2A), fillColor: scheme.surface,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16), const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -126,33 +131,31 @@ class AdaptiveTheme {
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: borderSide: BorderSide(color: scheme.outline, width: 1),
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: borderSide: BorderSide(color: scheme.primary, width: 1.5),
const BorderSide(color: AppColors.primaryColor, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), borderSide: BorderSide(color: scheme.error, width: 1),
), ),
labelStyle: TextStyle( labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
hintStyle: TextStyle( hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5), color: scheme.onSurfaceVariant,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor, backgroundColor: scheme.primary,
foregroundColor: Colors.white, foregroundColor: scheme.onPrimary,
minimumSize: const Size(0, 48), minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -161,8 +164,66 @@ class AdaptiveTheme {
elevation: 0, elevation: 0,
), ),
), ),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary.withValues(alpha: 0.5);
}
return scheme.surfaceContainerHighest.withValues(alpha: 0.5);
}),
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return Colors.transparent;
}),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: BorderSide(color: scheme.outline, width: 1.5),
),
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return scheme.primary;
}
return scheme.onSurfaceVariant;
}),
),
sliderTheme: SliderThemeData(
activeTrackColor: scheme.primary,
inactiveTrackColor: scheme.onSurfaceVariant,
thumbColor: scheme.primary,
overlayColor: scheme.primary.withValues(alpha: 0.5),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
),
tabBarTheme: TabBarThemeData(
labelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
indicatorColor: scheme.primary,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1), color: scheme.outline,
thickness: 1, thickness: 1,
space: 16, space: 16,
), ),
@@ -171,19 +232,15 @@ class AdaptiveTheme {
/// OLED 최적화 다크 테마 /// OLED 최적화 다크 테마
static ThemeData get oledTheme { static ThemeData get oledTheme {
return darkTheme.copyWith( final base = darkTheme;
const oledSurface = Color(0xFF0A0A0A);
return base.copyWith(
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith( colorScheme: base.colorScheme.copyWith(surface: oledSurface),
surface: const Color(0xFF0A0A0A), cardTheme: base.cardTheme.copyWith(color: oledSurface),
), appBarTheme: base.appBarTheme.copyWith(backgroundColor: Colors.black),
cardTheme: darkTheme.cardTheme.copyWith( inputDecorationTheme: base.inputDecorationTheme.copyWith(
color: const Color(0xFF0A0A0A), fillColor: oledSurface,
),
appBarTheme: darkTheme.appBarTheme.copyWith(
backgroundColor: Colors.black,
),
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
fillColor: const Color(0xFF0A0A0A),
), ),
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,10 +3,10 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart'; import '../../providers/subscription_provider.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../theme/app_colors.dart'; // Glass 제거: Material 3 Card 사용
import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../theme/color_scheme_ext.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯 /// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget { class EventAnalysisCard extends StatelessWidget {
@@ -38,10 +38,17 @@ class EventAnalysisCard extends StatelessWidget {
parent: animationController, parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut), curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)), )),
child: GlassmorphismCard( child: Card(
blur: 10, elevation: 3,
opacity: 0.1, shape: RoundedRectangleBorder(
borderRadius: 16, borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -64,20 +71,18 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color:
colors: [ Theme.of(context).colorScheme.error,
Color(0xFFFF6B6B),
Color(0xFFFE7E7E),
],
),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
children: [ children: [
const FaIcon( FaIcon(
FontAwesomeIcons.fire, FontAwesomeIcons.fire,
size: 12, size: 12,
color: AppColors.pureWhite, color: Theme.of(context)
.colorScheme
.onError,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -85,10 +90,12 @@ class EventAnalysisCard extends StatelessWidget {
.servicesInProgress(provider .servicesInProgress(provider
.activeEventSubscriptions .activeEventSubscriptions
.length), .length),
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: Theme.of(context)
.colorScheme
.onError,
), ),
), ),
], ],
@@ -100,27 +107,24 @@ class EventAnalysisCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: Theme.of(context)
colors: [ .colorScheme
const Color(0xFFFF6B6B) .error
.withValues(alpha: 0.1), .withValues(alpha: 0.08),
const Color(0xFFFF8787)
.withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: const Color(0xFFFF6B6B) color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
), ),
), ),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.savings, Icons.savings,
color: Color(0xFFFF6B6B), color:
Theme.of(context).colorScheme.error,
size: 32, size: 32,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -142,10 +146,12 @@ class EventAnalysisCard extends StatelessWidget {
CurrencyUtil.formatTotalAmount( CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(), provider.calculateTotalSavings(),
), ),
style: const TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B), color: Theme.of(context)
.colorScheme
.error,
), ),
), ),
], ],
@@ -173,12 +179,16 @@ class EventAnalysisCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.darkNavy color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.05), .withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.darkNavy color: Theme.of(context)
.withValues(alpha: 0.1), .colorScheme
.onSurfaceVariant
.withValues(alpha: 0.2),
), ),
), ),
child: Row( child: Row(
@@ -207,13 +217,15 @@ class EventAnalysisCard extends StatelessWidget {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
snapshot.data!, snapshot.data!,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
decoration: decoration:
TextDecoration TextDecoration
.lineThrough, .lineThrough,
color: AppColors color: Theme.of(
.navyGray, context)
.colorScheme
.onSurfaceVariant,
), ),
); );
} }
@@ -221,10 +233,12 @@ class EventAnalysisCard extends StatelessWidget {
}, },
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon( Icon(
Icons.arrow_forward, Icons.arrow_forward,
size: 12, size: 12,
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FutureBuilder<String>( FutureBuilder<String>(
@@ -237,12 +251,14 @@ class EventAnalysisCard extends StatelessWidget {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
snapshot.data!, snapshot.data!,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: fontWeight:
FontWeight.bold, FontWeight.bold,
color: color:
Color(0xFF10B981), Theme.of(context)
.colorScheme
.success,
), ),
); );
} }
@@ -260,24 +276,29 @@ class EventAnalysisCard extends StatelessWidget {
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF6B6B) color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
borderRadius: borderRadius:
BorderRadius.circular(4), BorderRadius.circular(4),
), ),
child: Text( child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}', _formatDiscountPercent(
style: const TextStyle( context, discountRate),
style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B), color: Theme.of(context)
.colorScheme
.error,
), ),
), ),
), ),
], ],
), ),
); );
}).toList(), }),
], ],
), ),
), ),
@@ -291,3 +312,17 @@ class EventAnalysisCard extends StatelessWidget {
); );
} }
} }
String _formatDiscountPercent(BuildContext context, int percent) {
final raw = AppLocalizations.of(context).discountPercent;
// 우선 @ 플레이스홀더가 있으면 치환
if (raw.contains('@')) {
return raw.replaceAll('@', percent.toString());
}
// % 마커가 있으면 첫 번째 %를 숫자%로 치환
if (raw.contains('%')) {
return raw.replaceFirst('%', '$percent%');
}
// 폴백: "99% text" 형태
return '$percent% $raw';
}

View File

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

View File

@@ -4,8 +4,9 @@ import 'package:provider/provider.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../services/exchange_rate_service.dart'; import '../../services/exchange_rate_service.dart';
import '../../theme/app_colors.dart'; // import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../../theme/color_scheme_ext.dart';
// Glass 제거: Material 3 Card 사용
import '../themed_text.dart'; import '../themed_text.dart';
import 'analysis_badge.dart'; import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -30,17 +31,18 @@ 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
@@ -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(

View File

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

View File

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

View File

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

View File

@@ -15,14 +15,14 @@ class CategoryHeaderWidget extends StatelessWidget {
final double totalCostCNY; final double totalCostCNY;
const CategoryHeaderWidget({ const CategoryHeaderWidget({
Key? key, super.key,
required this.categoryName, required this.categoryName,
required this.subscriptionCount, required this.subscriptionCount,
required this.totalCostUSD, required this.totalCostUSD,
required this.totalCostKRW, required this.totalCostKRW,
required this.totalCostJPY, required this.totalCostJPY,
required this.totalCostCNY, required this.totalCostCNY,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -36,27 +36,27 @@ class CategoryHeaderWidget extends StatelessWidget {
children: [ children: [
Text( Text(
categoryName, categoryName,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF374151), color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
Text( Text(
_buildCostDisplay(context), _buildCostDisplay(context),
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF6B7280), color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Divider( Divider(
height: 1, height: 1,
thickness: 1, thickness: 1,
color: Color(0xFFEEEEEE), color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
), ),
], ],
), ),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_colors.dart'; import '../../widgets/native_ad_widget.dart';
import '../../widgets/themed_text.dart'; import '../../widgets/themed_text.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -8,14 +8,17 @@ class ScanLoadingWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Column(
children: [
const NativeAdWidget(key: ValueKey('sms_scan_loading_ad')),
const SizedBox(height: 48),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const CircularProgressIndicator( CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor), color: Theme.of(context).colorScheme.primary,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ThemedText( ThemedText(
@@ -31,6 +34,7 @@ class ScanLoadingWidget extends StatelessWidget {
], ],
), ),
), ),
],
); );
} }
} }

View File

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

View File

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

View File

@@ -5,10 +5,12 @@ import '../providers/category_provider.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import '../utils/billing_date_util.dart';
import 'website_icon.dart'; import 'website_icon.dart';
import 'app_navigator.dart'; import 'app_navigator.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import 'glassmorphism_card.dart'; import '../theme/color_scheme_ext.dart';
// import 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
class SubscriptionCard extends StatefulWidget { class SubscriptionCard extends StatefulWidget {
@@ -30,6 +32,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
late AnimationController _hoverController; late AnimationController _hoverController;
bool _isHovering = false; bool _isHovering = false;
String? _displayName; String? _displayName;
static const int _nearBillingThresholdDays = 3;
@override @override
void initState() { void initState() {
@@ -107,6 +110,16 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 과거 날짜인 경우, 다음 결제일 계산 // 과거 날짜인 경우, 다음 결제일 계산
final billingCycle = widget.subscription.billingCycle; final billingCycle = widget.subscription.billingCycle;
final norm = BillingDateUtil.normalizeCycle(billingCycle);
// 분기/반기 구독 처리
if (norm == 'quarterly' || norm == 'half-yearly') {
final nextDate =
BillingDateUtil.ensureFutureDate(nextBillingDate, billingCycle);
final days = nextDate.difference(dateOnlyNow).inDays;
if (days == 0) return AppLocalizations.of(context).paymentDueToday;
return AppLocalizations.of(context).paymentDueInDays(days);
}
// 월간 구독인 경우 // 월간 구독인 경우
if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') { if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') {
@@ -211,26 +224,32 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return AppLocalizations.of(context).paymentInfoNeeded; return AppLocalizations.of(context).paymentInfoNeeded;
} }
// 결제일이 가까운지 확인 (7일 이내) int _daysUntilNextBilling() {
bool _isNearBilling() { final now = DateTime.now();
final text = _getNextBillingText(); final dateOnlyNow = DateTime(now.year, now.month, now.day);
if (text == AppLocalizations.of(context).paymentDueToday) return true; final nbd = widget.subscription.nextBillingDate;
final dateOnlyBilling = DateTime(nbd.year, nbd.month, nbd.day);
final regex = RegExp(r'(\d+)'); if (dateOnlyBilling.isAfter(dateOnlyNow)) {
final match = regex.firstMatch(text); return dateOnlyBilling.difference(dateOnlyNow).inDays;
if (match != null) {
final days = int.parse(match.group(1) ?? '0');
return days <= 7;
} }
return false; final next =
BillingDateUtil.ensureFutureDate(nbd, widget.subscription.billingCycle);
return next.difference(dateOnlyNow).inDays;
}
// 결제일이 가까운지 확인
bool _isNearBilling() {
final days = _daysUntilNextBilling();
return days <= _nearBillingThresholdDays;
} }
// 카테고리별 그라데이션 색상 생성 // 카테고리별 그라데이션 색상 생성
List<Color> _getCategoryGradientColors(BuildContext context) { List<Color> _getCategoryGradientColors(BuildContext context) {
try { try {
if (widget.subscription.categoryId == null) { if (widget.subscription.categoryId == null) {
return AppColors.blueGradient; return [Theme.of(context).colorScheme.primary];
} }
final categoryProvider = context.watch<CategoryProvider>(); final categoryProvider = context.watch<CategoryProvider>();
@@ -238,19 +257,16 @@ class _SubscriptionCardState extends State<SubscriptionCard>
categoryProvider.getCategoryById(widget.subscription.categoryId!); categoryProvider.getCategoryById(widget.subscription.categoryId!);
if (category == null) { if (category == null) {
return AppColors.blueGradient; return [Theme.of(context).colorScheme.primary];
} }
final categoryColor = final categoryColor =
Color(int.parse(category.color.replaceAll('#', '0xFF'))); Color(int.parse(category.color.replaceAll('#', '0xFF')));
return [ return [categoryColor];
categoryColor,
categoryColor.withValues(alpha: 0.8),
];
} catch (e) { } catch (e) {
// 색상 파싱 실패 시 기본 파란색 그라데이션 반환 // 색상 파싱 실패 시 기본 primary 색 반환
return AppColors.blueGradient; return [Theme.of(context).colorScheme.primary];
} }
} }
@@ -296,11 +312,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: MouseRegion( child: MouseRegion(
onEnter: (_) => _onHover(true), onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false), onExit: (_) => _onHover(false),
child: AnimatedGlassmorphismCard( child: Card(
padding: EdgeInsets.zero, elevation: _isHovering ? 2 : 1,
borderRadius: 16, shape: RoundedRectangleBorder(
blur: _isHovering ? 15 : 10, borderRadius: BorderRadius.circular(16),
width: double.infinity, // 전체 너비를 차지하도록 설정 side: BorderSide(
color:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.4),
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap ?? onTap: widget.onTap ??
() async { () async {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
@@ -312,20 +335,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
height: 4, height: 4,
decoration: BoxDecoration( // 카테고리 우선: 상단 바는 항상 카테고리 색
gradient: LinearGradient( color: _getCategoryGradientColors(context).first,
colors: widget.subscription.isCurrentlyInEvent
? [
const Color(0xFFFF6B6B),
const Color(0xFFFF8787),
]
: isNearBilling
? AppColors.amberGradient
: _getCategoryGradientColors(context),
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
), ),
Padding( Padding(
@@ -357,11 +368,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: Text( child: Text(
_displayName ?? _displayName ??
widget.subscription.serviceName, widget.subscription.serviceName,
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 18, fontSize: 18,
color: AppColors color: Theme.of(context)
.darkNavy, // color.md 가이드: 메인 텍스트 .colorScheme
.onSurface,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -381,29 +393,32 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vertical: 3, vertical: 3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color: Theme.of(context)
colors: [ .colorScheme
Color(0xFFFF6B6B), .error,
Color(0xFFFF8787), borderRadius:
], BorderRadius.circular(12),
),
borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Icon(
Icons.local_offer_rounded, Icons.local_offer_rounded,
size: 11, size: 11,
color: AppColors.pureWhite, color: Theme.of(context)
.colorScheme
.onError,
), ),
const SizedBox(width: 3), const SizedBox(width: 3),
Text( Text(
AppLocalizations.of(context).event, AppLocalizations.of(context)
style: const TextStyle( .event,
style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.pureWhite, color: Theme.of(context)
.colorScheme
.onError,
), ),
), ),
], ],
@@ -419,22 +434,28 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vertical: 3, vertical: 3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceColorAlt, color: Theme.of(context)
.colorScheme
.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.borderColor, color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
width: 0.5, width: 0.5,
), ),
), ),
child: Text( child: Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.getBillingCycleName( .getBillingCycleName(widget
widget.subscription.billingCycle), .subscription.billingCycle),
style: const TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors color: Theme.of(context)
.navyGray, // color.md 가이드: 서브 텍스트 .colorScheme
.onSurfaceVariant,
), ),
), ),
), ),
@@ -458,17 +479,20 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return const SizedBox(); return const SizedBox();
} }
if (widget.subscription.isCurrentlyInEvent && if (widget
.subscription.isCurrentlyInEvent &&
snapshot.data!.contains('|')) { snapshot.data!.contains('|')) {
final prices = snapshot.data!.split('|'); final prices = snapshot.data!.split('|');
return Row( return Row(
children: [ children: [
Text( Text(
prices[0], prices[0],
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.navyGray, color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
decoration: decoration:
TextDecoration.lineThrough, TextDecoration.lineThrough,
), ),
@@ -476,10 +500,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
prices[1], prices[1],
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFFFF6B6B), color: Theme.of(context)
.colorScheme
.error,
), ),
), ),
], ],
@@ -490,10 +516,14 @@ class _SubscriptionCardState extends State<SubscriptionCard>
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: widget color: widget.subscription
.subscription.isCurrentlyInEvent .isCurrentlyInEvent
? const Color(0xFFFF6B6B) ? Theme.of(context)
: AppColors.primaryColor, .colorScheme
.error
: Theme.of(context)
.colorScheme
.primary,
), ),
); );
} }
@@ -507,10 +537,13 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vertical: 3, vertical: 3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isNearBilling color: (isNearBilling
? AppColors.warningColor ? Theme.of(context)
.withValues(alpha: 0.1) .colorScheme
: AppColors.successColor .warning
: Theme.of(context)
.colorScheme
.success)
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -523,8 +556,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
: Icons.check_circle_rounded, : Icons.check_circle_rounded,
size: 12, size: 12,
color: isNearBilling color: isNearBilling
? AppColors.warningColor ? Theme.of(context)
: AppColors.successColor, .colorScheme
.warning
: Theme.of(context)
.colorScheme
.success,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -533,8 +570,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isNearBilling color: isNearBilling
? AppColors.warningColor ? Theme.of(context)
: AppColors.successColor, .colorScheme
.warning
: Theme.of(context)
.colorScheme
.success,
), ),
), ),
], ],
@@ -555,17 +596,21 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF6B6B) color: Theme.of(context)
.colorScheme
.error
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Icon(
Icons.savings_rounded, Icons.savings_rounded,
size: 14, size: 14,
color: Color(0xFFFF6B6B), color: Theme.of(context)
.colorScheme
.error,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
// 이벤트 절약액 표시 (언어별 통화) // 이벤트 절약액 표시 (언어별 통화)
@@ -581,10 +626,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} }
return Text( return Text(
'${snapshot.data!} ${AppLocalizations.of(context).saving}', '${snapshot.data!} ${AppLocalizations.of(context).saving}',
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Color(0xFFFF6B6B), color: Theme.of(context)
.colorScheme
.error,
), ),
); );
}, },
@@ -597,14 +644,16 @@ class _SubscriptionCardState extends State<SubscriptionCard>
if (widget.subscription.eventEndDate != if (widget.subscription.eventEndDate !=
null) ...[ null) ...[
Text( Text(
AppLocalizations.of(context).daysRemaining( AppLocalizations.of(context)
widget.subscription.eventEndDate! .daysRemaining(widget
.subscription.eventEndDate!
.difference(DateTime.now()) .difference(DateTime.now())
.inDays), .inDays),
style: const TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: AppColors color: Theme.of(context)
.navyGray, // color.md 가이드: 서브 텍스트 .colorScheme
.onSurfaceVariant,
), ),
), ),
], ],
@@ -621,6 +670,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -20,10 +20,10 @@ class SubscriptionListWidget extends StatelessWidget {
final AnimationController fadeController; final AnimationController fadeController;
const SubscriptionListWidget({ const SubscriptionListWidget({
Key? key, super.key,
required this.categorizedSubscriptions, required this.categorizedSubscriptions,
required this.fadeController, required this.fadeController,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -188,9 +188,9 @@ class MultiSliver extends StatelessWidget {
final List<Widget> children; final List<Widget> children;
const MultiSliver({ const MultiSliver({
Key? key, super.key,
required this.children, required this.children,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; // Color resolution now relies on Theme ColorScheme.
/// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯 /// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯
class ThemedText extends StatelessWidget { class ThemedText extends StatelessWidget {
@@ -40,28 +40,12 @@ class ThemedText extends StatelessWidget {
bool forceLight = false, bool forceLight = false,
bool forceDark = false, bool forceDark = false,
}) { }) {
if (forceLight) return AppColors.pureWhite; final scheme = Theme.of(context).colorScheme;
if (forceDark) return AppColors.darkNavy; if (forceLight) return scheme.onPrimary; // typically white
if (forceDark) return scheme.onSurface; // dark text in light theme
final brightness = Theme.of(context).brightness; // 기본: 스킴의 onSurface 사용(라이트/다크 자동 대비)
return scheme.onSurface;
// 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용
if (_isGlassmorphicContext(context)) {
return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트
}
// 일반 환경
return brightness == Brightness.dark
? AppColors.pureWhite
: AppColors.darkNavy;
}
/// 글래스모피즘 컨텍스트인지 확인
static bool _isGlassmorphicContext(BuildContext context) {
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
final glassmorphic =
context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
return glassmorphic != null;
} }
@override @override
@@ -176,40 +160,3 @@ class ThemedText extends StatelessWidget {
); );
} }
} }
/// 글래스모피즘 컨텍스트를 표시하는 마커 위젯
class GlassmorphicIndicator extends InheritedWidget {
const GlassmorphicIndicator({
super.key,
required super.child,
});
static GlassmorphicIndicator? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<GlassmorphicIndicator>();
}
@override
bool updateShouldNotify(GlassmorphicIndicator oldWidget) => false;
}
/// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼
class GlassmorphicTextWrapper extends StatelessWidget {
final Widget child;
const GlassmorphicTextWrapper({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return GlassmorphicIndicator(
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
color: ThemedText.getContrastColor(context),
),
child: child,
),
);
}
}

View File

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

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