feat: 다국어 지원 및 다중 통화 환율 변환 기능 확대

- ExchangeRateService에 JPY, CNY 환율 지원 추가
- 구독 서비스별 다국어 표시 이름 지원
- 분석 화면 차트 및 UI/UX 개선
- 설정 화면 전면 리팩토링
- SMS 스캔 기능 사용성 개선
- 전체 앱 다국어 번역 확대

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-16 17:34:32 +09:00
parent 4d1c0f5dab
commit 0f0b02bf08
55 changed files with 4100 additions and 1197 deletions

BIN
assets/appicon/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because it is too large Load Diff

874
assets/data/text.json Normal file
View File

@@ -0,0 +1,874 @@
{
"en": {
"appTitle": "Digital Rent Manager",
"appSubtitle": "Manage subscriptions easily",
"subscriptionManagement": "Subscription Management",
"addSubscription": "Add Subscription",
"subscriptionName": "Service Name",
"monthlyCost": "Monthly Cost",
"billingCycle": "Billing Cycle",
"nextBillingDate": "Next Billing Date",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"totalSubscriptions": "Total Subscriptions",
"totalMonthlyExpense": "Total Monthly Expense",
"noSubscriptions": "No subscriptions registered",
"addSubscriptionNow": "Add Subscription Now",
"paymentReminder": "Payment Reminder",
"expirationReminder": "Expiration Reminder",
"daysLeft": "days left",
"categoryManagement": "Category Management",
"categoryName": "Category Name",
"selectColor": "Select Color",
"selectIcon": "Select Icon",
"addCategory": "Add Category",
"settings": "Settings",
"darkMode": "Dark Mode",
"language": "Language",
"notifications": "Notifications",
"appLock": "App Lock",
"notificationPermission": "Notification Permission",
"notificationPermissionDesc": "Permission is required to receive notifications",
"requestPermission": "Request Permission",
"paymentNotification": "Payment Due Notification",
"paymentNotificationDesc": "Receive notification on payment due date",
"notificationTiming": "Notification Timing",
"daysBefore": "day(s) before",
"notificationTime": "Notification Time",
"dailyReminder": "Daily Reminder",
"dailyReminderEnabled": "Receive daily notifications until payment date",
"dailyReminderDisabled": "Receive notification @ day(s) before payment",
"notificationPermissionDenied": "Notification permission denied",
"appInfo": "App Info",
"version": "Version",
"appDescription": "Digital Rent Management App",
"developer": "Developer",
"cannotOpenStore": "Cannot open store",
"lightTheme": "Light",
"darkTheme": "Dark",
"oledTheme": "OLED Black",
"systemTheme": "System Default",
"subscriptionAdded": "Subscription added",
"subscriptionAddedTemplate": "@ subscription added.",
"korean": "한국어",
"english": "English",
"japanese": "日本語",
"chinese": "中文",
"oneDayBefore": "1 day before",
"twoDaysBefore": "2 days before",
"threeDaysBefore": "3 days before",
"requiredFieldsError": "Please fill in all required fields",
"subscriptionUpdated": "Subscription information has been updated",
"subscriptionDeleted": "@ subscription has been deleted",
"officialCancelPageNotFound": "Official cancellation page not found. Redirecting to Google search.",
"cannotOpenWebsite": "Cannot open website",
"noWebsiteInfo": "No website information available. Please cancel through the website.",
"editMode": "Edit Mode",
"changesAppliedAfterSave": "Changes will be applied after saving",
"saveChanges": "Save Changes",
"monthlyExpense": "Monthly Expense",
"websiteUrl": "Website URL",
"websiteUrlOptional": "Website URL (Optional)",
"eventPrice": "Event Price",
"eventPriceHint": "Enter discounted price",
"eventPriceRequired": "Please enter event price",
"invalidPrice": "Please enter a valid price",
"smsScanLabel": "SMS",
"home": "Home",
"analysis": "Analysis",
"back": "Back",
"exitApp": "Exit App",
"exitAppConfirm": "Are you sure you want to exit SubManager?",
"exit": "Exit",
"pageNotFound": "Page not found",
"serviceNameExample": "e.g. Netflix, Spotify",
"urlExample": "https://example.com",
"appLockDesc": "App lock with biometric authentication",
"unlockWithBiometric": "Unlock with biometric authentication",
"authenticationFailed": "Authentication failed. Please try again.",
"totalExpenseCopied": "Total expense copied: @",
"smsPermissionRequired": "SMS permission required",
"noSubscriptionSmsFound": "No subscription related SMS found",
"serviceRecognized": "@ service has been recognized automatically.",
"smsScanError": "Error occurred during SMS scan: @",
"saveError": "Error occurred while saving: @",
"newSubscriptionSmsNotFound": "No new subscription SMS found",
"subscriptionAddError": "Error adding subscription: @",
"subscriptionSkipped": "@ subscription skipped.",
"allSubscriptionsProcessed": "All subscriptions have been processed.",
"websiteUrlExtracted": "Website URL (Auto-extracted)",
"startDate": "Start Date",
"endDate": "End Date",
"mySubscriptions": "My Subscriptions",
"monthlyExpenseTitle": "Monthly Expense Status",
"recentSixMonthsTrend": "Recent 6 months trend",
"monthlySubscriptionExpense": "Monthly subscription expense",
"subscriptionServiceRatio": "Subscription Service Ratio",
"monthlyExpenseBasis": "Based on monthly expense",
"noSubscriptionServices": "No subscription services",
"totalExpenseSummary": "Total Expense Summary",
"monthlyTotalAmount": "Monthly Total Amount",
"totalExpense": "Total Expense",
"totalServices": "Total Services",
"servicesUnit": "services",
"averageCost": "Average Cost",
"eventDiscountStatus": "Event Discount Status",
"inProgressUnit": "in progress",
"monthlySavingAmount": "Monthly Saving Amount",
"eventsInProgress": "Events in Progress",
"discountPercent": "% discount",
"currencyWon": "KRW",
"scanningMessages": "Scanning SMS messages...",
"findingSubscriptions": "Finding subscription services",
"subscriptionNotFound": "Subscription information not found.",
"repeatSubscriptionNotFound": "No repeated subscription information found.",
"newSubscriptionNotFound": "No new subscription SMS found",
"findRepeatSubscriptions": "Find subscriptions paid 2+ times",
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.",
"startScanning": "Start Scanning",
"foundSubscription": "Found subscription",
"serviceName": "Service Name",
"nextBillingDateLabel": "Next Billing Date",
"category": "Category",
"websiteUrlAuto": "Website URL (Auto-extracted)",
"websiteUrlHint": "Edit website URL or leave empty",
"skip": "Skip",
"add": "Add",
"nextBillingDateRequired": "Next billing date verification required",
"nextBillingDateEstimated": "Next estimated billing date: @ (# days later)",
"nextBillingDateInfo": "Next billing date: @ (# days later)",
"nextBillingDatePastRequired": "Next billing date verification required (past date)",
"repeatCountDetected": "@ payment(s) detected",
"monthlyTotalSubscriptionCost": "Total Monthly Subscription Cost",
"todaysExchangeRate": "Today's Exchange Rate",
"won": "KRW",
"estimatedAnnualCost": "Estimated Annual Cost",
"totalSubscriptionServices": "Total Subscription Services",
"eventDiscountActive": "Event Discount Active",
"saving": "Saving",
"paymentDueToday": "Payment Due Today",
"paymentDueInDays": "Payment due in @ days",
"paymentInfoNeeded": "Payment Info Needed",
"event": "Event",
"daysRemaining": "@ days remaining",
"exchangeRateFormat": "Today's rate: @",
"categoryMusic": "Music",
"categoryOttVideo": "OTT(Video)",
"categoryStorageCloud": "Storage/Cloud",
"categoryTelecomInternetTv": "Telecom · Internet · TV",
"categoryLifestyle": "Lifestyle",
"categoryShoppingEcommerce": "Shopping/E-commerce",
"categoryProgramming": "Programming",
"categoryCollaborationOffice": "Collaboration/Office",
"categoryAiService": "AI Service",
"categoryOther": "Other",
"monthly": "Monthly",
"weekly": "Weekly",
"yearly": "Yearly",
"colorBlue": "Blue",
"colorGreen": "Green",
"colorOrange": "Orange",
"colorRed": "Red",
"colorPurple": "Purple",
"dateFormatFull": "MMM dd, yyyy",
"dateFormatShort": "MM/dd",
"exchangeRateDisplay": "$1 = @",
"labelServiceName": "Service Name",
"hintServiceName": "e.g. Netflix, Spotify",
"labelMonthlyExpense": "Monthly Expense",
"labelNextBillingDate": "Next Billing Date",
"labelWebsiteUrl": "Website URL (Optional)",
"hintWebsiteUrl": "https://example.com",
"labelEventPrice": "Event Price",
"hintEventPrice": "Enter discounted price",
"labelCategory": "Category",
"subscription": "Subscription",
"movie": "Movie",
"music": "Music",
"exercise": "Exercise",
"shopping": "Shopping",
"currency": "Currency",
"billingCycleMonthly": "Monthly",
"billingCycleQuarterly": "Quarterly",
"billingCycleHalfYearly": "Half-Yearly",
"billingCycleYearly": "Yearly",
"websiteInfo": "Website Information",
"cancelGuide": "Cancellation Guide",
"cancelServiceGuide": "To cancel this service, please go to the cancellation page through the link below.",
"goToCancelPage": "Go to Cancellation Page",
"urlAutoMatchInfo": "If URL is empty, it will be automatically matched based on the service name",
"discountPercent": "@% discount",
"discountAmountWon": "Save ₩@",
"discountAmountDollar": "Save $@",
"discountAmountYen": "Save ¥@",
"discountAmountYuan": "Save ¥@",
"billingCyclePayment": "@ Payment",
"dateSelect": "Select",
"billingCycleSuffix": "",
"serviceInfo": "Service Information",
"newSubscriptionAdd": "Add New Subscription",
"enterServiceInfo": "Enter service information",
"addSubscriptionButton": "Add Subscription",
"serviceNameRequired": "Please enter service name",
"amountRequired": "Please enter amount",
"subscriptionDetail": "Subscription Detail",
"enterAmount": "Enter amount",
"invalidAmount": "Please enter a valid amount"
},
"ko": {
"appTitle": "디지털 월세 관리자",
"appSubtitle": "구독 서비스 관리를 더 쉽게",
"subscriptionManagement": "구독 관리",
"addSubscription": "구독 추가",
"subscriptionName": "서비스명",
"monthlyCost": "월 비용",
"billingCycle": "결제 주기",
"nextBillingDate": "다음 결제일",
"save": "저장",
"cancel": "취소",
"delete": "삭제",
"edit": "수정",
"totalSubscriptions": "총 구독",
"totalMonthlyExpense": "이번 달 총 지출",
"noSubscriptions": "등록된 구독 서비스가 없습니다",
"addSubscriptionNow": "구독 추가하기",
"paymentReminder": "결제 예정 알림",
"expirationReminder": "만료 예정 알림",
"daysLeft": "일 남음",
"categoryManagement": "카테고리 관리",
"categoryName": "카테고리 이름",
"selectColor": "색상 선택",
"selectIcon": "아이콘 선택",
"addCategory": "카테고리 추가",
"settings": "설정",
"darkMode": "다크 모드",
"language": "언어",
"notifications": "알림",
"appLock": "앱 잠금",
"notificationPermission": "알림 권한",
"notificationPermissionDesc": "알림을 받으려면 권한이 필요합니다",
"requestPermission": "권한 요청",
"paymentNotification": "결제 예정 알림",
"paymentNotificationDesc": "결제 예정일 알림 받기",
"notificationTiming": "알림 시점",
"daysBefore": "일 전",
"notificationTime": "알림 시간",
"dailyReminder": "1일마다 반복 알림",
"dailyReminderEnabled": "결제일까지 매일 알림을 받습니다",
"dailyReminderDisabled": "결제 @일 전에 알림을 받습니다",
"notificationPermissionDenied": "알림 권한이 거부되었습니다",
"appInfo": "앱 정보",
"version": "버전",
"appDescription": "디지털 월세 관리 앱",
"developer": "개발자",
"cannotOpenStore": "스토어를 열 수 없습니다",
"lightTheme": "라이트",
"darkTheme": "다크",
"oledTheme": "OLED 블랙",
"systemTheme": "시스템 설정",
"subscriptionAdded": "구독이 추가되었습니다",
"subscriptionAddedTemplate": "@ 구독이 추가되었습니다.",
"korean": "한국어",
"english": "English",
"japanese": "日本語",
"chinese": "中文",
"oneDayBefore": "1일 전",
"twoDaysBefore": "2일 전",
"threeDaysBefore": "3일 전",
"requiredFieldsError": "필수 항목을 모두 입력해주세요",
"subscriptionUpdated": "구독 정보가 업데이트되었습니다.",
"subscriptionDeleted": "@ 구독이 삭제되었습니다.",
"officialCancelPageNotFound": "공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.",
"cannotOpenWebsite": "웹사이트를 열 수 없습니다.",
"noWebsiteInfo": "웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.",
"editMode": "편집 모드",
"changesAppliedAfterSave": "변경사항은 저장 후 적용됩니다",
"saveChanges": "변경사항 저장",
"monthlyExpense": "월 지출",
"websiteUrl": "웹사이트 URL",
"websiteUrlOptional": "웹사이트 URL (선택)",
"eventPrice": "이벤트 가격",
"eventPriceHint": "할인된 가격을 입력하세요",
"eventPriceRequired": "이벤트 가격을 입력해주세요",
"invalidPrice": "올바른 가격을 입력해주세요",
"smsScanLabel": "SMS",
"home": "홈",
"analysis": "분석",
"back": "뒤로가기",
"exitApp": "앱 종료",
"exitAppConfirm": "SubManager를 종료하시겠습니까?",
"exit": "종료",
"pageNotFound": "페이지를 찾을 수 없습니다",
"serviceNameExample": "예: Netflix, Spotify",
"urlExample": "https://example.com",
"appLockDesc": "생체 인증으로 앱 잠금",
"unlockWithBiometric": "생체 인증으로 잠금 해제",
"authenticationFailed": "인증에 실패했습니다. 다시 시도해주세요.",
"totalExpenseCopied": "총 지출액이 복사되었습니다: @",
"smsPermissionRequired": "SMS 권한이 필요합니다.",
"noSubscriptionSmsFound": "구독 관련 SMS를 찾을 수 없습니다.",
"serviceRecognized": "@ 서비스가 자동으로 인식되었습니다.",
"smsScanError": "SMS 스캔 중 오류 발생: @",
"saveError": "저장 중 오류가 발생했습니다: @",
"newSubscriptionSmsNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
"subscriptionAddError": "구독 추가 중 오류가 발생했습니다: @",
"subscriptionSkipped": "@ 구독을 건너뛰었습니다.",
"allSubscriptionsProcessed": "모든 구독이 처리되었습니다.",
"websiteUrlExtracted": "웹사이트 URL (자동 추출됨)",
"startDate": "시작일",
"endDate": "종료일",
"mySubscriptions": "나의 구독 서비스",
"monthlyExpenseTitle": "월별 지출 현황",
"recentSixMonthsTrend": "최근 6개월간 추이",
"monthlySubscriptionExpense": "월 구독 지출",
"subscriptionServiceRatio": "구독 서비스 비율",
"monthlyExpenseBasis": "월 지출 기준",
"noSubscriptionServices": "구독중인 서비스가 없습니다",
"totalExpenseSummary": "총 지출 요약",
"monthlyTotalAmount": "월 단위 총액",
"totalExpense": "총 지출",
"totalServices": "총 서비스",
"servicesUnit": "개",
"averageCost": "평균 요금",
"eventDiscountStatus": "이벤트 할인 현황",
"inProgressUnit": "진행중",
"monthlySavingAmount": "월간 절약 금액",
"eventsInProgress": "진행중인 이벤트",
"discountPercent": "% 할인",
"currencyWon": "원",
"scanningMessages": "SMS 메시지를 스캔 중입니다...",
"findingSubscriptions": "구독 서비스를 찾고 있습니다",
"subscriptionNotFound": "구독 정보를 찾을 수 없습니다.",
"repeatSubscriptionNotFound": "반복 결제된 구독 정보를 찾을 수 없습니다.",
"newSubscriptionNotFound": "신규 구독 관련 SMS를 찾을 수 없습니다",
"findRepeatSubscriptions": "2회 이상 결제된 구독 서비스 찾기",
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.",
"startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다",
"serviceName": "서비스명",
"nextBillingDateLabel": "다음 결제일",
"category": "카테고리",
"websiteUrlAuto": "웹사이트 URL (자동 추출됨)",
"websiteUrlHint": "웹사이트 URL을 수정하거나 비워두세요",
"skip": "건너뛰기",
"add": "추가하기",
"nextBillingDateRequired": "다음 결제일 확인 필요",
"nextBillingDateEstimated": "다음 예상 결제일: @ (#일 후)",
"nextBillingDateInfo": "다음 결제일: @ (#일 후)",
"nextBillingDatePastRequired": "다음 결제일 확인 필요 (과거 날짜)",
"repeatCountDetected": "@회 결제 감지됨",
"monthlyTotalSubscriptionCost": "이번 달 총 구독 비용",
"todaysExchangeRate": "오늘 기준 환율",
"won": "원",
"estimatedAnnualCost": "예상 연간 구독 비용",
"totalSubscriptionServices": "총 구독 서비스",
"eventDiscountActive": "이벤트 할인 중",
"saving": "절약",
"paymentDueToday": "오늘 결제 예정",
"paymentDueInDays": "@일 후 결제 예정",
"paymentInfoNeeded": "결제일 정보 필요",
"event": "이벤트",
"daysRemaining": "@일 남음",
"exchangeRateFormat": "오늘 기준 환율: @",
"categoryMusic": "음악",
"categoryOttVideo": "OTT(동영상)",
"categoryStorageCloud": "저장/클라우드",
"categoryTelecomInternetTv": "통신 · 인터넷 · TV",
"categoryLifestyle": "생활/라이프스타일",
"categoryShoppingEcommerce": "쇼핑/이커머스",
"categoryProgramming": "프로그래밍",
"categoryCollaborationOffice": "협업/오피스",
"categoryAiService": "AI 서비스",
"categoryOther": "기타",
"monthly": "월간",
"weekly": "주간",
"yearly": "연간",
"colorBlue": "파란색",
"colorGreen": "초록색",
"colorOrange": "주황색",
"colorRed": "빨간색",
"colorPurple": "보라색",
"dateFormatFull": "yyyy년 MM월 dd일",
"dateFormatShort": "MM/dd",
"exchangeRateDisplay": "$1 = @",
"labelServiceName": "서비스명",
"hintServiceName": "예: Netflix, Spotify",
"labelMonthlyExpense": "월 지출",
"labelNextBillingDate": "다음 결제일",
"labelWebsiteUrl": "웹사이트 URL (선택)",
"hintWebsiteUrl": "https://example.com",
"labelEventPrice": "이벤트 가격",
"hintEventPrice": "할인된 가격을 입력하세요",
"labelCategory": "카테고리",
"subscription": "구독",
"movie": "영화",
"music": "음악",
"exercise": "운동",
"shopping": "쇼핑",
"currency": "통화",
"billingCycleMonthly": "매월",
"billingCycleQuarterly": "분기별",
"billingCycleHalfYearly": "반기별",
"billingCycleYearly": "매년",
"websiteInfo": "웹사이트 정보",
"cancelGuide": "해지 안내",
"cancelServiceGuide": "이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.",
"goToCancelPage": "해지 페이지로 이동",
"urlAutoMatchInfo": "URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다",
"discountPercent": "@% 할인",
"discountAmountWon": "₩@원 절약",
"discountAmountDollar": "$@ 절약",
"discountAmountYen": "¥@ 절약",
"discountAmountYuan": "¥@ 절약",
"billingCyclePayment": "@ 결제",
"dateSelect": "선택",
"billingCycleSuffix": "",
"serviceInfo": "서비스 정보",
"newSubscriptionAdd": "새 구독 추가",
"enterServiceInfo": "서비스 정보를 입력해주세요",
"addSubscriptionButton": "구독 추가하기",
"serviceNameRequired": "서비스명을 입력해주세요",
"amountRequired": "금액을 입력해주세요",
"subscriptionDetail": "구독 상세",
"enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요"
},
"ja": {
"appTitle": "デジタル月額管理者",
"appSubtitle": "サブスクリプションを簡単に管理",
"subscriptionManagement": "サブスクリプション管理",
"addSubscription": "サブスクリプション追加",
"subscriptionName": "サービス名",
"monthlyCost": "月額費用",
"billingCycle": "請求サイクル",
"nextBillingDate": "次回請求日",
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"totalSubscriptions": "総サブスクリプション",
"totalMonthlyExpense": "今月の総支出",
"noSubscriptions": "登録されたサブスクリプションはありません",
"addSubscriptionNow": "サブスクリプションを追加",
"paymentReminder": "支払い予定通知",
"expirationReminder": "有効期限通知",
"daysLeft": "日残り",
"categoryManagement": "カテゴリー管理",
"categoryName": "カテゴリー名",
"selectColor": "色を選択",
"selectIcon": "アイコンを選択",
"addCategory": "カテゴリー追加",
"settings": "設定",
"darkMode": "ダークモード",
"language": "言語",
"notifications": "通知",
"appLock": "アプリロック",
"notificationPermission": "通知権限",
"notificationPermissionDesc": "通知を受け取るには権限が必要です",
"requestPermission": "権限をリクエスト",
"paymentNotification": "支払い予定通知",
"paymentNotificationDesc": "支払い予定日に通知を受け取る",
"notificationTiming": "通知タイミング",
"daysBefore": "日前",
"notificationTime": "通知時刻",
"dailyReminder": "毎日リマインダー",
"dailyReminderEnabled": "支払い日まで毎日通知を受け取ります",
"dailyReminderDisabled": "支払い@日前に通知を受け取ります",
"notificationPermissionDenied": "通知権限が拒否されました",
"appInfo": "アプリ情報",
"version": "バージョン",
"appDescription": "デジタル月額管理アプリ",
"developer": "開発者",
"cannotOpenStore": "ストアを開けません",
"lightTheme": "ライト",
"darkTheme": "ダーク",
"oledTheme": "OLEDブラック",
"systemTheme": "システム設定",
"subscriptionAdded": "サブスクリプションが追加されました",
"subscriptionAddedTemplate": "@のサブスクリプションが追加されました。",
"korean": "한국어",
"english": "English",
"japanese": "日本語",
"chinese": "中文",
"oneDayBefore": "1日前",
"twoDaysBefore": "2日前",
"threeDaysBefore": "3日前",
"requiredFieldsError": "すべての必須項目を入力してください",
"subscriptionUpdated": "サブスクリプション情報が更新されました",
"subscriptionDeleted": "@サブスクリプションが削除されました",
"officialCancelPageNotFound": "公式解約ページが見つかりません。Google検索にリダイレクトします。",
"cannotOpenWebsite": "ウェブサイトを開けません",
"noWebsiteInfo": "ウェブサイト情報がありません。ウェブサイトから解約してください。",
"editMode": "編集モード",
"changesAppliedAfterSave": "変更は保存後に適用されます",
"saveChanges": "変更を保存",
"monthlyExpense": "月額支出",
"websiteUrl": "ウェブサイトURL",
"websiteUrlOptional": "ウェブサイトURLオプション",
"eventPrice": "イベント価格",
"eventPriceHint": "割引価格を入力してください",
"eventPriceRequired": "イベント価格を入力してください",
"invalidPrice": "有効な価格を入力してください",
"smsScanLabel": "SMS",
"home": "ホーム",
"analysis": "分析",
"back": "戻る",
"exitApp": "アプリを終了",
"exitAppConfirm": "SubManagerを終了しますか",
"exit": "終了",
"pageNotFound": "ページが見つかりません",
"serviceNameExample": "例Netflix、Spotify",
"urlExample": "https://example.com",
"appLockDesc": "生体認証でアプリをロック",
"unlockWithBiometric": "生体認証でロック解除",
"authenticationFailed": "認証に失敗しました。もう一度お試しください。",
"totalExpenseCopied": "総支出がコピーされました:@",
"smsPermissionRequired": "SMS権限が必要です",
"noSubscriptionSmsFound": "サブスクリプション関連のSMSが見つかりません",
"serviceRecognized": "@サービスが自動的に認識されました",
"smsScanError": "SMSスキャン中にエラーが発生しました@",
"saveError": "保存中にエラーが発生しました:@",
"newSubscriptionSmsNotFound": "新しいサブスクリプションSMSが見つかりません",
"subscriptionAddError": "サブスクリプション追加中にエラーが発生しました:@",
"subscriptionSkipped": "@サブスクリプションをスキップしました",
"allSubscriptionsProcessed": "すべてのサブスクリプションが処理されました",
"websiteUrlExtracted": "ウェブサイトURL自動抽出",
"startDate": "開始日",
"endDate": "終了日",
"mySubscriptions": "マイサブスクリプション",
"monthlyExpenseTitle": "月別支出状況",
"recentSixMonthsTrend": "直近6ヶ月の推移",
"monthlySubscriptionExpense": "月間サブスクリプション支出",
"subscriptionServiceRatio": "サブスクリプションサービス比率",
"monthlyExpenseBasis": "月間支出基準",
"noSubscriptionServices": "サブスクリプションサービスがありません",
"totalExpenseSummary": "総支出サマリー",
"monthlyTotalAmount": "月単位の総額",
"totalExpense": "総支出",
"totalServices": "総サービス",
"servicesUnit": "個",
"averageCost": "平均費用",
"eventDiscountStatus": "イベント割引状況",
"inProgressUnit": "進行中",
"monthlySavingAmount": "月間節約額",
"eventsInProgress": "進行中のイベント",
"discountPercent": "% 割引",
"currencyWon": "ウォン",
"scanningMessages": "SMSメッセージをスキャン中...",
"findingSubscriptions": "サブスクリプションサービスを検索中",
"subscriptionNotFound": "サブスクリプション情報が見つかりません。",
"repeatSubscriptionNotFound": "繰り返し決済されたサブスクリプション情報が見つかりません。",
"newSubscriptionNotFound": "新規サブスクリプションSMSが見つかりません",
"findRepeatSubscriptions": "2回以上決済されたサブスクリプションを検索",
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。サービス名と金額を抽出して簡単にサブスクリプションを追加できます。",
"startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました",
"serviceName": "サービス名",
"nextBillingDateLabel": "次回請求日",
"category": "カテゴリー",
"websiteUrlAuto": "ウェブサイトURL自動抽出",
"websiteUrlHint": "ウェブサイトURLを編集するか空にしてください",
"skip": "スキップ",
"add": "追加",
"nextBillingDateRequired": "次回請求日の確認が必要です",
"nextBillingDateEstimated": "次回予想請求日:@#日後)",
"nextBillingDateInfo": "次回請求日:@#日後)",
"nextBillingDatePastRequired": "次回請求日の確認が必要です(過去の日付)",
"repeatCountDetected": "@回の決済が検出されました",
"monthlyTotalSubscriptionCost": "今月の総サブスクリプション費用",
"todaysExchangeRate": "今日の為替レート",
"won": "ウォン",
"estimatedAnnualCost": "予想年間サブスクリプション費用",
"totalSubscriptionServices": "総サブスクリプションサービス",
"eventDiscountActive": "イベント割引中",
"saving": "節約",
"paymentDueToday": "本日支払い予定",
"paymentDueInDays": "@日後に支払い予定",
"paymentInfoNeeded": "支払い日情報が必要",
"event": "イベント",
"daysRemaining": "@日残り",
"exchangeRateFormat": "今日のレート: @",
"categoryMusic": "音楽",
"categoryOttVideo": "OTT(動画)",
"categoryStorageCloud": "ストレージ/クラウド",
"categoryTelecomInternetTv": "通信・インターネット・TV",
"categoryLifestyle": "ライフスタイル",
"categoryShoppingEcommerce": "ショッピング/Eコマース",
"categoryProgramming": "プログラミング",
"categoryCollaborationOffice": "コラボレーション/オフィス",
"categoryAiService": "AIサービス",
"categoryOther": "その他",
"monthly": "月間",
"weekly": "週間",
"yearly": "年間",
"colorBlue": "青",
"colorGreen": "緑",
"colorOrange": "オレンジ",
"colorRed": "赤",
"colorPurple": "紫",
"dateFormatFull": "yyyy年MM月dd日",
"dateFormatShort": "MM/dd",
"exchangeRateDisplay": "$1 = @",
"labelServiceName": "サービス名",
"hintServiceName": "例Netflix、Spotify",
"labelMonthlyExpense": "月額支出",
"labelNextBillingDate": "次回請求日",
"labelWebsiteUrl": "ウェブサイトURLオプション",
"hintWebsiteUrl": "https://example.com",
"labelEventPrice": "イベント価格",
"hintEventPrice": "割引価格を入力してください",
"labelCategory": "カテゴリー",
"subscription": "サブスクリプション",
"movie": "映画",
"music": "音楽",
"exercise": "運動",
"shopping": "ショッピング",
"currency": "通貨",
"billingCycleMonthly": "毎月",
"billingCycleQuarterly": "四半期",
"billingCycleHalfYearly": "半年ごと",
"billingCycleYearly": "年間",
"websiteInfo": "ウェブサイト情報",
"cancelGuide": "解約案内",
"cancelServiceGuide": "このサービスを解約するには、以下のリンクから解約ページに移動してください。",
"goToCancelPage": "解約ページへ移動",
"urlAutoMatchInfo": "URLが空の場合、サービス名に基づいて自動的にマッチングされます",
"discountPercent": "@%割引",
"discountAmountWon": "₩@節約",
"discountAmountDollar": "$@節約",
"discountAmountYen": "¥@節約",
"discountAmountYuan": "¥@節約",
"billingCyclePayment": "@払い",
"dateSelect": "選択",
"billingCycleSuffix": "払い",
"serviceInfo": "サービス情報",
"newSubscriptionAdd": "新規サブスクリプション追加",
"enterServiceInfo": "サービス情報を入力してください",
"addSubscriptionButton": "サブスクリプションを追加",
"serviceNameRequired": "サービス名を入力してください",
"amountRequired": "金額を入力してください",
"subscriptionDetail": "サブスクリプション詳細",
"enterAmount": "金額を入力してください",
"invalidAmount": "正しい金額を入力してください"
},
"zh": {
"appTitle": "数字月租管理器",
"appSubtitle": "轻松管理订阅服务",
"subscriptionManagement": "订阅管理",
"addSubscription": "添加订阅",
"subscriptionName": "服务名称",
"monthlyCost": "每月费用",
"billingCycle": "付款周期",
"nextBillingDate": "下次付款日期",
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"totalSubscriptions": "订阅总数",
"totalMonthlyExpense": "本月总支出",
"noSubscriptions": "没有注册的订阅服务",
"addSubscriptionNow": "添加订阅",
"paymentReminder": "付款提醒",
"expirationReminder": "到期提醒",
"daysLeft": "天剩余",
"categoryManagement": "分类管理",
"categoryName": "分类名称",
"selectColor": "选择颜色",
"selectIcon": "选择图标",
"addCategory": "添加分类",
"settings": "设置",
"darkMode": "深色模式",
"language": "语言",
"notifications": "通知",
"appLock": "应用锁定",
"notificationPermission": "通知权限",
"notificationPermissionDesc": "需要权限才能接收通知",
"requestPermission": "请求权限",
"paymentNotification": "付款到期通知",
"paymentNotificationDesc": "在付款到期日收到通知",
"notificationTiming": "通知时间",
"daysBefore": "天前",
"notificationTime": "通知时间",
"dailyReminder": "每日提醒",
"dailyReminderEnabled": "直到付款日期每天接收通知",
"dailyReminderDisabled": "在付款@天前接收通知",
"notificationPermissionDenied": "通知权限被拒绝",
"appInfo": "应用信息",
"version": "版本",
"appDescription": "数字月租管理应用",
"developer": "开发者",
"cannotOpenStore": "无法打开商店",
"lightTheme": "浅色",
"darkTheme": "深色",
"oledTheme": "OLED黑色",
"systemTheme": "系统默认",
"subscriptionAdded": "订阅已添加",
"subscriptionAddedTemplate": "@订阅已添加。",
"korean": "한국어",
"english": "English",
"japanese": "日本語",
"chinese": "中文",
"oneDayBefore": "1天前",
"twoDaysBefore": "2天前",
"threeDaysBefore": "3天前",
"requiredFieldsError": "请填写所有必填项",
"subscriptionUpdated": "订阅信息已更新",
"subscriptionDeleted": "@订阅已删除",
"officialCancelPageNotFound": "找不到官方取消页面。重定向到Google搜索。",
"cannotOpenWebsite": "无法打开网站",
"noWebsiteInfo": "没有网站信息。请通过网站取消。",
"editMode": "编辑模式",
"changesAppliedAfterSave": "更改将在保存后应用",
"saveChanges": "保存更改",
"monthlyExpense": "每月支出",
"websiteUrl": "网站URL",
"websiteUrlOptional": "网站URL可选",
"eventPrice": "活动价格",
"eventPriceHint": "输入折扣价格",
"eventPriceRequired": "请输入活动价格",
"invalidPrice": "请输入有效的价格",
"smsScanLabel": "短信",
"home": "主页",
"analysis": "分析",
"back": "返回",
"exitApp": "退出应用",
"exitAppConfirm": "确定要退出SubManager吗",
"exit": "退出",
"pageNotFound": "找不到页面",
"serviceNameExample": "例如Netflix、Spotify",
"urlExample": "https://example.com",
"appLockDesc": "使用生物识别锁定应用",
"unlockWithBiometric": "使用生物识别解锁",
"authenticationFailed": "认证失败。请重试。",
"totalExpenseCopied": "总支出已复制:@",
"smsPermissionRequired": "需要短信权限",
"noSubscriptionSmsFound": "未找到订阅相关的短信",
"serviceRecognized": "@服务已自动识别",
"smsScanError": "短信扫描时出错:@",
"saveError": "保存时出错:@",
"newSubscriptionSmsNotFound": "未找到新订阅短信",
"subscriptionAddError": "添加订阅时出错:@",
"subscriptionSkipped": "已跳过@订阅",
"allSubscriptionsProcessed": "所有订阅已处理",
"websiteUrlExtracted": "网站URL自动提取",
"startDate": "开始日期",
"endDate": "结束日期",
"mySubscriptions": "我的订阅",
"monthlyExpenseTitle": "月度支出状况",
"recentSixMonthsTrend": "最近6个月趋势",
"monthlySubscriptionExpense": "月度订阅支出",
"subscriptionServiceRatio": "订阅服务比例",
"monthlyExpenseBasis": "基于月度支出",
"noSubscriptionServices": "没有订阅服务",
"totalExpenseSummary": "总支出摘要",
"monthlyTotalAmount": "月度总额",
"totalExpense": "总支出",
"totalServices": "总服务",
"servicesUnit": "个",
"averageCost": "平均费用",
"eventDiscountStatus": "活动折扣状态",
"inProgressUnit": "进行中",
"monthlySavingAmount": "月度节省金额",
"eventsInProgress": "进行中的活动",
"discountPercent": "% 折扣",
"currencyWon": "韩元",
"scanningMessages": "正在扫描短信...",
"findingSubscriptions": "正在查找订阅服务",
"subscriptionNotFound": "未找到订阅信息。",
"repeatSubscriptionNotFound": "未找到重复付款的订阅信息。",
"newSubscriptionNotFound": "未找到新订阅短信",
"findRepeatSubscriptions": "查找支付2次以上的订阅",
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。可以提取服务名称和金额,轻松添加订阅。",
"startScanning": "开始扫描",
"foundSubscription": "找到订阅",
"serviceName": "服务名称",
"nextBillingDateLabel": "下次付款日期",
"category": "类别",
"websiteUrlAuto": "网站URL自动提取",
"websiteUrlHint": "编辑网站URL或留空",
"skip": "跳过",
"add": "添加",
"nextBillingDateRequired": "需要确认下次付款日期",
"nextBillingDateEstimated": "预计下次付款日期:@#天后)",
"nextBillingDateInfo": "下次付款日期:@#天后)",
"nextBillingDatePastRequired": "需要确认下次付款日期(过去日期)",
"repeatCountDetected": "检测到@次付款",
"monthlyTotalSubscriptionCost": "本月总订阅费用",
"todaysExchangeRate": "今日汇率",
"won": "韩元",
"estimatedAnnualCost": "预计年度订阅费用",
"totalSubscriptionServices": "总订阅服务",
"eventDiscountActive": "活动折扣中",
"saving": "节省",
"paymentDueToday": "今日付款到期",
"paymentDueInDays": "@天后付款到期",
"paymentInfoNeeded": "需要付款日期信息",
"event": "活动",
"daysRemaining": "剩余@天",
"exchangeRateFormat": "今日汇率: @",
"categoryMusic": "音乐",
"categoryOttVideo": "OTT(视频)",
"categoryStorageCloud": "存储/云",
"categoryTelecomInternetTv": "电信·互联网·电视",
"categoryLifestyle": "生活方式",
"categoryShoppingEcommerce": "购物/电子商务",
"categoryProgramming": "编程",
"categoryCollaborationOffice": "协作/办公",
"categoryAiService": "AI服务",
"categoryOther": "其他",
"monthly": "月付",
"weekly": "周付",
"yearly": "年付",
"colorBlue": "蓝色",
"colorGreen": "绿色",
"colorOrange": "橙色",
"colorRed": "红色",
"colorPurple": "紫色",
"dateFormatFull": "yyyy年MM月dd日",
"dateFormatShort": "MM/dd",
"exchangeRateDisplay": "$1 = @",
"labelServiceName": "服务名称",
"hintServiceName": "例如Netflix、Spotify",
"labelMonthlyExpense": "每月支出",
"labelNextBillingDate": "下次付款日期",
"labelWebsiteUrl": "网站URL可选",
"hintWebsiteUrl": "https://example.com",
"labelEventPrice": "活动价格",
"hintEventPrice": "输入折扣价格",
"labelCategory": "类别",
"subscription": "订阅",
"movie": "电影",
"music": "音乐",
"exercise": "运动",
"shopping": "购物",
"currency": "货币",
"billingCycleMonthly": "每月",
"billingCycleQuarterly": "每季度",
"billingCycleHalfYearly": "每半年",
"billingCycleYearly": "每年",
"websiteInfo": "网站信息",
"cancelGuide": "取消指南",
"cancelServiceGuide": "要取消此服务,请通过以下链接转到取消页面。",
"goToCancelPage": "前往取消页面",
"urlAutoMatchInfo": "如果URL为空将根据服务名称自动匹配",
"discountPercent": "@%折扣",
"discountAmountWon": "节省₩@",
"discountAmountDollar": "节省$@",
"discountAmountYen": "节省¥@",
"discountAmountYuan": "节省¥@",
"billingCyclePayment": "@付款",
"dateSelect": "选择",
"billingCycleSuffix": "付款",
"serviceInfo": "服务信息",
"newSubscriptionAdd": "添加新订阅",
"enterServiceInfo": "输入服务信息",
"addSubscriptionButton": "添加订阅",
"serviceNameRequired": "请输入服务名称",
"amountRequired": "请输入金额",
"subscriptionDetail": "订阅详情",
"enterAmount": "请输入金额",
"invalidAmount": "请输入有效的金额"
}
}

View File

@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController { class AddSubscriptionController {
@@ -23,7 +25,7 @@ class AddSubscriptionController {
final eventPriceController = TextEditingController(); final eventPriceController = TextEditingController();
// Form State // Form State
String billingCycle = '월간'; String billingCycle = 'monthly';
String currency = 'KRW'; String currency = 'KRW';
DateTime? nextBillingDate; DateTime? nextBillingDate;
bool isLoading = false; bool isLoading = false;
@@ -172,7 +174,7 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showSuccess( AppSnackBar.showSuccess(
context: context, context: context,
message: '${serviceInfo.serviceName} 서비스가 자동으로 인식되었습니다.', message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName),
); );
} }
} }
@@ -215,7 +217,7 @@ class AddSubscriptionController {
serviceName.contains('플로') || serviceName.contains('플로') ||
serviceName.contains('벅스')) { serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '음악', (cat) => cat.name == 'music',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -284,7 +286,7 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: 'SMS 권한이 필요합니다.', message: AppLocalizations.of(context).smsPermissionRequired,
); );
} }
return; return;
@@ -296,7 +298,7 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showWarning( AppSnackBar.showWarning(
context: context, context: context,
message: '구독 관련 SMS를 찾을 수 없습니다.', message: AppLocalizations.of(context).noSubscriptionSmsFound,
); );
} }
return; return;
@@ -394,7 +396,7 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: 'SMS 스캔 중 오류 발생: $e', message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()),
); );
} }
} finally { } finally {
@@ -450,7 +452,7 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '저장 중 오류가 발생했습니다: $e', message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
); );
} }
} }

View File

@@ -5,11 +5,13 @@ import '../models/subscription_model.dart';
import '../models/category_model.dart'; import '../models/category_model.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart'; 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';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier { class DetailScreenController extends ChangeNotifier {
@@ -22,6 +24,10 @@ class DetailScreenController extends ChangeNotifier {
late TextEditingController websiteUrlController; late TextEditingController websiteUrlController;
late TextEditingController eventPriceController; late TextEditingController eventPriceController;
// Display Names
String? _displayName;
String? get displayName => _displayName;
// Form State // Form State
final GlobalKey<FormState> formKey = GlobalKey<FormState>(); final GlobalKey<FormState> formKey = GlobalKey<FormState>();
late String _billingCycle; late String _billingCycle;
@@ -197,6 +203,9 @@ class DetailScreenController extends ChangeNotifier {
// 애니메이션 시작 // 애니메이션 시작
animationController!.forward(); animationController!.forward();
// 로케일에 맞는 서비스명 로드
_loadDisplayName();
// 서비스명 변경 감지 리스너 // 서비스명 변경 감지 리스너
serviceNameController.addListener(onServiceNameChanged); serviceNameController.addListener(onServiceNameChanged);
@@ -206,6 +215,20 @@ class DetailScreenController extends ChangeNotifier {
}); });
} }
/// 로케일에 맞는 서비스명 로드
Future<void> _loadDisplayName() async {
final localeProvider = context.read<LocaleProvider>();
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName,
locale: locale,
);
_displayName = displayName;
notifyListeners();
}
/// 리소스 정리 /// 리소스 정리
@override @override
void dispose() { void dispose() {
@@ -282,7 +305,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('플로') || serviceName.contains('플로') ||
serviceName.contains('벅스')) { serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '음악 서비스', (cat) => cat.name == 'music',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -295,7 +318,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('icloud') || serviceName.contains('icloud') ||
serviceName.contains('adobe')) { serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '오피스/협업 툴', (cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -306,7 +329,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('copilot') || serviceName.contains('copilot') ||
serviceName.contains('midjourney')) { serviceName.contains('midjourney')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == 'AI 서비스', (cat) => cat.name == 'aiService',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -317,7 +340,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('패스트캠퍼스') || serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) { serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '프로그래밍/개발', (cat) => cat.name == 'programming',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -328,7 +351,7 @@ class DetailScreenController extends ChangeNotifier {
serviceName.contains('네이버') || serviceName.contains('네이버') ||
serviceName.contains('11번가')) { serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '기타 서비스', (cat) => cat.name == 'other',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -344,7 +367,7 @@ class DetailScreenController extends ChangeNotifier {
if (formKey.currentState != null && !formKey.currentState!.validate()) { if (formKey.currentState != null && !formKey.currentState!.validate()) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '필수 항목을 모두 입력해주세요', message: AppLocalizations.of(context).requiredFieldsError,
); );
return; return;
} }
@@ -368,6 +391,10 @@ class DetailScreenController extends ChangeNotifier {
monthlyCost = subscription.monthlyCost; monthlyCost = subscription.monthlyCost;
} }
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, '
'금액: ${subscription.monthlyCost}$monthlyCost ${_currency}');
subscription.serviceName = serviceNameController.text; subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl; subscription.websiteUrl = websiteUrl;
@@ -393,13 +420,17 @@ class DetailScreenController extends ChangeNotifier {
subscription.eventPrice = null; subscription.eventPrice = null;
} }
debugPrint('[DetailScreenController] 업데이트 정보: '
'현재가격=${subscription.currentPrice}, '
'이벤트활성=${subscription.isEventActive}');
// 구독 업데이트 // 구독 업데이트
await provider.updateSubscription(subscription); await provider.updateSubscription(subscription);
if (context.mounted) { if (context.mounted) {
AppSnackBar.showSuccess( AppSnackBar.showSuccess(
context: context, context: context,
message: '구독 정보가 업데이트되었습니다.', message: AppLocalizations.of(context).subscriptionUpdated,
); );
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
@@ -413,10 +444,18 @@ class DetailScreenController extends ChangeNotifier {
/// 구독 삭제 /// 구독 삭제
Future<void> deleteSubscription() async { Future<void> deleteSubscription() async {
if (context.mounted) { if (context.mounted) {
// 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show( final shouldDelete = await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: subscription.serviceName, serviceName: displayName,
); );
if (!shouldDelete) return; if (!shouldDelete) return;
@@ -429,7 +468,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '구독이 삭제되었습니다.', message: AppLocalizations.of(context).subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded, icon: Icons.delete_forever_rounded,
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -459,7 +498,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showInfo( AppSnackBar.showInfo(
context: context, context: context,
message: '공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.', message: AppLocalizations.of(context).officialCancelPageNotFound,
); );
} }
} }
@@ -470,7 +509,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '웹사이트를 열 수 없습니다.', message: AppLocalizations.of(context).cannotOpenWebsite,
); );
} }
} }
@@ -487,7 +526,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '웹사이트를 열 수 없습니다.', message: AppLocalizations.of(context).cannotOpenWebsite,
); );
} }
} }
@@ -495,7 +534,7 @@ class DetailScreenController extends ChangeNotifier {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showWarning( AppSnackBar.showWarning(
context: context, context: context,
message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.', message: AppLocalizations.of(context).noWebsiteInfo,
); );
} }
} }

View File

@@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AppLocalizations { class AppLocalizations {
final Locale locale; final Locale locale;
late Map<String, dynamic> _localizedStrings;
AppLocalizations(this.locale); AppLocalizations(this.locale);
@@ -9,126 +12,467 @@ class AppLocalizations {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!; return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
} }
static const _localizedValues = <String, Map<String, String>>{ // JSON 파일에서 번역 데이터 로드
'en': { Future<void> load() async {
'appTitle': 'SubManager', String jsonString =
'subscriptionManagement': 'Subscription Management', await rootBundle.loadString('assets/data/text.json');
'addSubscription': 'Add Subscription', Map<String, dynamic> jsonMap = json.decode(jsonString);
'subscriptionName': 'Service Name', _localizedStrings = jsonMap[locale.languageCode];
'monthlyCost': 'Monthly Cost', }
'billingCycle': 'Billing Cycle',
'nextBillingDate': 'Next Billing Date',
'save': 'Save',
'cancel': 'Cancel',
'delete': 'Delete',
'edit': 'Edit',
'totalSubscriptions': 'Total Subscriptions',
'totalMonthlyExpense': 'Total Monthly Expense',
'noSubscriptions': 'No subscriptions registered',
'addSubscriptionNow': 'Add Subscription Now',
'paymentReminder': 'Payment Reminder',
'expirationReminder': 'Expiration Reminder',
'daysLeft': 'days left',
'categoryManagement': 'Category Management',
'categoryName': 'Category Name',
'selectColor': 'Select Color',
'selectIcon': 'Select Icon',
'addCategory': 'Add Category',
'settings': 'Settings',
'darkMode': 'Dark Mode',
'language': 'Language',
'notifications': 'Notifications',
'appLock': 'App Lock',
},
'ko': {
'appTitle': '구독 관리',
'subscriptionManagement': '구독 관리',
'addSubscription': '구독 추가',
'subscriptionName': '서비스명',
'monthlyCost': '월 비용',
'billingCycle': '결제 주기',
'nextBillingDate': '다음 결제일',
'save': '저장',
'cancel': '취소',
'delete': '삭제',
'edit': '수정',
'totalSubscriptions': '총 구독',
'totalMonthlyExpense': '이번 달 총 지출',
'noSubscriptions': '등록된 구독 서비스가 없습니다',
'addSubscriptionNow': '구독 추가하기',
'paymentReminder': '결제 예정 알림',
'expirationReminder': '만료 예정 알림',
'daysLeft': '일 남음',
'categoryManagement': '카테고리 관리',
'categoryName': '카테고리 이름',
'selectColor': '색상 선택',
'selectIcon': '아이콘 선택',
'addCategory': '카테고리 추가',
'settings': '설정',
'darkMode': '다크 모드',
'language': '언어',
'notifications': '알림',
'appLock': '앱 잠금',
},
};
String get appTitle => _localizedValues[locale.languageCode]!['appTitle']!; String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager';
String get appSubtitle => _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily';
String get subscriptionManagement => String get subscriptionManagement =>
_localizedValues[locale.languageCode]!['subscriptionManagement']!; _localizedStrings['subscriptionManagement'] ?? 'Subscription Management';
String get addSubscription => String get addSubscription =>
_localizedValues[locale.languageCode]!['addSubscription']!; _localizedStrings['addSubscription'] ?? 'Add Subscription';
String get subscriptionName => String get subscriptionName =>
_localizedValues[locale.languageCode]!['subscriptionName']!; _localizedStrings['subscriptionName'] ?? 'Service Name';
String get monthlyCost => String get monthlyCost =>
_localizedValues[locale.languageCode]!['monthlyCost']!; _localizedStrings['monthlyCost'] ?? 'Monthly Cost';
String get billingCycle => String get billingCycle =>
_localizedValues[locale.languageCode]!['billingCycle']!; _localizedStrings['billingCycle'] ?? 'Billing Cycle';
String get nextBillingDate => String get nextBillingDate =>
_localizedValues[locale.languageCode]!['nextBillingDate']!; _localizedStrings['nextBillingDate'] ?? 'Next Billing Date';
String get save => _localizedValues[locale.languageCode]!['save']!; String get save => _localizedStrings['save'] ?? 'Save';
String get cancel => _localizedValues[locale.languageCode]!['cancel']!; String get cancel => _localizedStrings['cancel'] ?? 'Cancel';
String get delete => _localizedValues[locale.languageCode]!['delete']!; String get delete => _localizedStrings['delete'] ?? 'Delete';
String get edit => _localizedValues[locale.languageCode]!['edit']!; String get edit => _localizedStrings['edit'] ?? 'Edit';
String get totalSubscriptions => String get totalSubscriptions =>
_localizedValues[locale.languageCode]!['totalSubscriptions']!; _localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions';
String get totalMonthlyExpense => String get totalMonthlyExpense =>
_localizedValues[locale.languageCode]!['totalMonthlyExpense']!; _localizedStrings['totalMonthlyExpense'] ?? 'Total Monthly Expense';
String get noSubscriptions => String get noSubscriptions =>
_localizedValues[locale.languageCode]!['noSubscriptions']!; _localizedStrings['noSubscriptions'] ?? 'No subscriptions registered';
String get addSubscriptionNow => String get addSubscriptionNow =>
_localizedValues[locale.languageCode]!['addSubscriptionNow']!; _localizedStrings['addSubscriptionNow'] ?? 'Add Subscription Now';
String get paymentReminder => String get paymentReminder =>
_localizedValues[locale.languageCode]!['paymentReminder']!; _localizedStrings['paymentReminder'] ?? 'Payment Reminder';
String get expirationReminder => String get expirationReminder =>
_localizedValues[locale.languageCode]!['expirationReminder']!; _localizedStrings['expirationReminder'] ?? 'Expiration Reminder';
String get daysLeft => _localizedValues[locale.languageCode]!['daysLeft']!; String get daysLeft => _localizedStrings['daysLeft'] ?? 'days left';
String get categoryManagement => String get categoryManagement =>
_localizedValues[locale.languageCode]!['categoryManagement']!; _localizedStrings['categoryManagement'] ?? 'Category Management';
String get categoryName => String get categoryName =>
_localizedValues[locale.languageCode]!['categoryName']!; _localizedStrings['categoryName'] ?? 'Category Name';
String get selectColor => String get selectColor =>
_localizedValues[locale.languageCode]!['selectColor']!; _localizedStrings['selectColor'] ?? 'Select Color';
String get selectIcon => String get selectIcon =>
_localizedValues[locale.languageCode]!['selectIcon']!; _localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory => String get addCategory =>
_localizedValues[locale.languageCode]!['addCategory']!; _localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedValues[locale.languageCode]!['settings']!; String get settings => _localizedStrings['settings'] ?? 'Settings';
String get darkMode => _localizedValues[locale.languageCode]!['darkMode']!; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedValues[locale.languageCode]!['language']!; String get language => _localizedStrings['language'] ?? 'Language';
String get notifications => String get notifications =>
_localizedValues[locale.languageCode]!['notifications']!; _localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedValues[locale.languageCode]!['appLock']!; String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
// 알림 설정
String get notificationPermission =>
_localizedStrings['notificationPermission'] ?? 'Notification Permission';
String get notificationPermissionDesc =>
_localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications';
String get requestPermission =>
_localizedStrings['requestPermission'] ?? 'Request Permission';
String get paymentNotification =>
_localizedStrings['paymentNotification'] ?? 'Payment Due Notification';
String get paymentNotificationDesc =>
_localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date';
String get notificationTiming =>
_localizedStrings['notificationTiming'] ?? 'Notification Timing';
String get notificationTime =>
_localizedStrings['notificationTime'] ?? 'Notification Time';
String get dailyReminder =>
_localizedStrings['dailyReminder'] ?? 'Daily Reminder';
String get dailyReminderEnabled =>
_localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date';
String get dailyReminderDisabled =>
_localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied';
// 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version';
String get appDescription =>
_localizedStrings['appDescription'] ?? 'Subscription Management App';
String get developer => _localizedStrings['developer'] ?? 'Developer';
String get cannotOpenStore =>
_localizedStrings['cannotOpenStore'] ?? 'Cannot open store';
// 테마
String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light';
String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark';
String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black';
String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default';
// 기타 메시지
String get subscriptionAdded =>
_localizedStrings['subscriptionAdded'] ?? 'Subscription added';
// 언어 설정
String get korean => _localizedStrings['korean'] ?? '한국어';
String get english => _localizedStrings['english'] ?? 'English';
String get japanese => _localizedStrings['japanese'] ?? '日本語';
String get chinese => _localizedStrings['chinese'] ?? '中文';
// 날짜
String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before';
String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before';
String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days before';
// 추가 메시지
String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields';
String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated';
String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.';
String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.';
String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode';
String get changesAppliedAfterSave => _localizedStrings['changesAppliedAfterSave'] ?? 'Changes will be applied after saving';
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
String get monthlyExpense => _localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
String get websiteUrlOptional => _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
String get home => _localizedStrings['home'] ?? 'Home';
String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
String get back => _localizedStrings['back'] ?? 'Back';
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App';
String get exitAppConfirm => _localizedStrings['exitAppConfirm'] ?? 'Are you sure you want to exit SubManager?';
String get exit => _localizedStrings['exit'] ?? 'Exit';
String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found';
String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com';
String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication';
String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication';
String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.';
String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found';
String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving';
String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found';
String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.';
String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)';
String get startDate => _localizedStrings['startDate'] ?? 'Start Date';
String get endDate => _localizedStrings['endDate'] ?? 'End Date';
// 새로 추가된 항목들
String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost';
String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
String get won => _localizedStrings['won'] ?? 'KRW';
String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services';
String get services => _localizedStrings['services'] ?? 'services';
String get eventDiscountActive => _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
String get saving => _localizedStrings['saving'] ?? 'Saving';
String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
String get event => _localizedStrings['event'] ?? 'Event';
// 카테고리 getter들
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV';
String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming';
String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office';
String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service';
String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
// 동적 메시지 생성 메서드
String daysBefore(int days) {
return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}';
}
String dailyReminderDisabledWithDays(int days) {
final template = _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
return template.replaceAll('@', days.toString());
}
String subscriptionAddedWithName(String serviceName) {
final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
return template.replaceAll('@', serviceName);
}
String subscriptionDeleted(String serviceName) {
final template = _localizedStrings['subscriptionDeleted'] ?? '@ subscription has been deleted';
return template.replaceAll('@', serviceName);
}
String totalExpenseCopied(String amount) {
final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
return template.replaceAll('@', amount);
}
String serviceRecognized(String serviceName) {
final template = _localizedStrings['serviceRecognized'] ?? '@ service has been recognized automatically.';
return template.replaceAll('@', serviceName);
}
String smsScanErrorWithMessage(String error) {
final template = _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan: @';
return template.replaceAll('@', error);
}
String saveErrorWithMessage(String error) {
final template = _localizedStrings['saveError'] ?? 'Error occurred while saving: @';
return template.replaceAll('@', error);
}
String subscriptionAddErrorWithMessage(String error) {
final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @';
return template.replaceAll('@', error);
}
String subscriptionSkipped(String serviceName) {
final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
return template.replaceAll('@', serviceName);
}
// 홈화면 관련
String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
String subscriptionCount(int count) {
if (locale.languageCode == 'ko') {
return '${count}';
} else if (locale.languageCode == 'ja') {
return '${count}';
} else if (locale.languageCode == 'zh') {
return '${count}';
} else {
return count.toString();
}
}
// 분석화면 관련
String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense';
String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio';
String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense';
String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services';
String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress';
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount';
String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
// SMS 스캔 관련
String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services';
String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.';
String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.';
String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found';
String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times';
String get scanTextMessages => _localizedStrings['scanTextMessages'] ?? 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning';
String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get nextBillingDateLabel => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category';
String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
String get skip => _localizedStrings['skip'] ?? 'Skip';
String get add => _localizedStrings['add'] ?? 'Add';
String get nextBillingDateRequired => _localizedStrings['nextBillingDateRequired'] ?? 'Next billing date verification required';
String nextBillingDateEstimated(String date, int days) {
final template = _localizedStrings['nextBillingDateEstimated'] ?? 'Next estimated billing date: @ (# days later)';
return template.replaceAll('@', date).replaceAll('#', days.toString());
}
String nextBillingDateInfo(String date, int days) {
final template = _localizedStrings['nextBillingDateInfo'] ?? 'Next billing date: @ (# days later)';
return template.replaceAll('@', date).replaceAll('#', days.toString());
}
String get nextBillingDatePastRequired => _localizedStrings['nextBillingDatePastRequired'] ?? 'Next billing date verification required (past date)';
String formatDate(DateTime date) {
if (locale.languageCode == 'ko') {
return '${date.year}${date.month}${date.day}';
} else if (locale.languageCode == 'ja') {
return '${date.year}${date.month}${date.day}';
} else if (locale.languageCode == 'zh') {
return '${date.year}${date.month}${date.day}';
} else {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
}
String repeatCountDetected(int count) {
final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
return template.replaceAll('@', count.toString());
}
String servicesInProgress(int count) {
if (locale.languageCode == 'ko') {
return '${count}개 진행중';
} else if (locale.languageCode == 'ja') {
return '${count}個進行中';
} else if (locale.languageCode == 'zh') {
return '${count}个进行中';
} else {
return '$count in progress';
}
}
// 새로 추가된 동적 메서드들
String paymentDueInDays(int days) {
final template = _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days';
return template.replaceAll('@', days.toString());
}
String daysRemaining(int days) {
final template = _localizedStrings['daysRemaining'] ?? '@ days remaining';
return template.replaceAll('@', days.toString());
}
String exchangeRateFormat(String rate) {
final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
return template.replaceAll('@', rate);
}
// 결제 주기 결제 메시지
String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment';
// 할인 금액 표시 getter들
String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@';
String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@';
String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@';
String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
// 결제 주기 관련 getter
String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly';
String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly';
// 색상 관련 getter
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green';
String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange';
String get colorRed => _localizedStrings['colorRed'] ?? 'Red';
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
// 날짜 형식 관련 getter
String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
// USD 환율 표시 형식
String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
// 라벨 및 힌트 텍스트
String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name';
String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price';
String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
// 기타 번역
String get subscription => _localizedStrings['subscription'] ?? 'Subscription';
String get movie => _localizedStrings['movie'] ?? 'Movie';
String get music => _localizedStrings['music'] ?? 'Music';
String get exercise => _localizedStrings['exercise'] ?? 'Exercise';
String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
String get currency => _localizedStrings['currency'] ?? 'Currency';
String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information';
String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.';
String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name';
String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
// 새로 추가된 getter들
String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information';
String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information';
String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount';
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) {
switch (billingCycleKey) {
case 'monthly':
case '월간':
case '月間':
case '月付':
return monthly;
case 'weekly':
case '주간':
case '週間':
case '周付':
return weekly;
case 'yearly':
case '연간':
case '年間':
case '年付':
return yearly;
default:
return billingCycleKey; // 매칭되지 않으면 원본 반환
}
}
// 카테고리 이름을 키로 변환하여 번역된 이름 반환
String getCategoryName(String categoryKey) {
switch (categoryKey) {
case '음악':
return categoryMusic;
case 'OTT(동영상)':
return categoryOttVideo;
case '저장/클라우드':
return categoryStorageCloud;
case '통신 · 인터넷 · TV':
return categoryTelecomInternetTv;
case '생활/라이프스타일':
return categoryLifestyle;
case '쇼핑/이커머스':
return categoryShoppingEcommerce;
case '프로그래밍':
return categoryProgramming;
case '협업/오피스':
return categoryCollaborationOffice;
case 'AI 서비스':
return categoryAiService;
case '기타':
return categoryOther;
default:
return categoryKey; // 매칭되지 않으면 원본 반환
}
}
} }
class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> { class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const AppLocalizationsDelegate(); const AppLocalizationsDelegate();
@override @override
bool isSupported(Locale locale) => ['en', 'ko'].contains(locale.languageCode); bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
@override @override
Future<AppLocalizations> load(Locale locale) async { Future<AppLocalizations> load(Locale locale) async {
return AppLocalizations(locale); final localizations = AppLocalizations(locale);
await localizations.load();
return localizations;
} }
@override @override

View File

@@ -120,7 +120,8 @@ class SubManagerApp extends StatelessWidget {
AdaptiveTheme.applySystemUIOverlay(context); AdaptiveTheme.applySystemUIOverlay(context);
return MaterialApp( return MaterialApp(
title: 'SubManager', key: ValueKey(localeProvider.locale),
title: 'Digital Rent Manager',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: themeProvider.getTheme(context), theme: themeProvider.getTheme(context),
locale: localeProvider.locale, locale: localeProvider.locale,
@@ -133,6 +134,8 @@ class SubManagerApp extends StatelessWidget {
supportedLocales: const [ supportedLocales: const [
Locale('en'), Locale('en'),
Locale('ko'), Locale('ko'),
Locale('ja'),
Locale('zh'),
], ],
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
navigatorObservers: [AppNavigationObserver()], navigatorObservers: [AppNavigationObserver()],
@@ -147,7 +150,8 @@ class SubManagerApp extends StatelessWidget {
return MediaQuery( return MediaQuery(
data: MediaQuery.of(context).copyWith( data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(themeProvider.largeText ? 1.2 : 1.0), textScaler:
TextScaler.linear(themeProvider.largeText ? 1.2 : 1.0),
disableAnimations: themeProvider.reduceMotion, disableAnimations: themeProvider.reduceMotion,
), ),
child: child!, child: child!,

View File

@@ -14,7 +14,7 @@ class SubscriptionModel extends HiveObject {
double monthlyCost; double monthlyCost;
@HiveField(3) @HiveField(3)
String billingCycle; // '월간', '연간', '주간' 등 String billingCycle; // 'monthly', 'yearly', 'weekly' - 영어 키값 사용
@HiveField(4) @HiveField(4)
DateTime nextBillingDate; DateTime nextBillingDate;
@@ -98,6 +98,32 @@ class SubscriptionModel extends HiveObject {
// 원래 가격 (이벤트와 관계없이 항상 정상 가격) // 원래 가격 (이벤트와 관계없이 항상 정상 가격)
double get originalPrice => monthlyCost; double get originalPrice => monthlyCost;
// 결제 주기를 영어 키값으로 정규화
static String normalizeBillingCycle(String cycle) {
switch (cycle.toLowerCase()) {
case 'monthly':
case '월간':
case '月間':
case '月付':
return 'monthly';
case 'weekly':
case '주간':
case '週間':
case '周付':
return 'weekly';
case 'yearly':
case '연간':
case '年間':
case '年付':
return 'yearly';
default:
return 'monthly'; // 기본값은 monthly
}
}
// 결제 주기를 영어 키값으로 반환 (내부 사용)
String get billingCycleKey => normalizeBillingCycle(billingCycle);
} }
// Hive TypeAdapter 생성을 위한 명령어 // Hive TypeAdapter 생성을 위한 명령어

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import '../models/category_model.dart'; import '../models/category_model.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../l10n/app_localizations.dart';
class CategoryProvider extends ChangeNotifier { class CategoryProvider extends ChangeNotifier {
List<CategoryModel> _categories = []; List<CategoryModel> _categories = [];
@@ -9,16 +10,16 @@ class CategoryProvider extends ChangeNotifier {
// 카테고리 표시 순서 정의 // 카테고리 표시 순서 정의
static const List<String> _categoryOrder = [ static const List<String> _categoryOrder = [
'음악', 'music',
'OTT(동영상)', 'ottVideo',
'저장/클라우드', 'storageCloud',
'통신 · 인터넷 · TV', 'telecomInternetTv',
'생활/라이프스타일', 'lifestyle',
'쇼핑/이커머스', 'shoppingEcommerce',
'프로그래밍', 'programming',
'협업/오피스', 'collaborationOffice',
'AI 서비스', 'aiService',
'기타', 'other',
]; ];
List<CategoryModel> get categories { List<CategoryModel> get categories {
@@ -53,16 +54,16 @@ class CategoryProvider extends ChangeNotifier {
// 기본 카테고리 초기화 // 기본 카테고리 초기화
Future<void> _initDefaultCategories() async { Future<void> _initDefaultCategories() async {
final defaultCategories = [ final defaultCategories = [
{'name': '음악', 'color': '#E91E63', 'icon': 'music_note'}, {'name': 'music', 'color': '#E91E63', 'icon': 'music_note'},
{'name': 'OTT(동영상)', 'color': '#9C27B0', 'icon': 'movie_filter'}, {'name': 'ottVideo', 'color': '#9C27B0', 'icon': 'movie_filter'},
{'name': '저장/클라우드', 'color': '#2196F3', 'icon': 'cloud'}, {'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
{'name': '통신 · 인터넷 · TV', 'color': '#00BCD4', 'icon': 'wifi'}, {'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
{'name': '생활/라이프스타일', 'color': '#4CAF50', 'icon': 'home'}, {'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'},
{'name': '쇼핑/이커머스', 'color': '#FF9800', 'icon': 'shopping_cart'}, {'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'},
{'name': '프로그래밍', 'color': '#795548', 'icon': 'code'}, {'name': 'programming', 'color': '#795548', 'icon': 'code'},
{'name': '협업/오피스', 'color': '#607D8B', 'icon': 'business_center'}, {'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'},
{'name': 'AI 서비스', 'color': '#673AB7', 'icon': 'smart_toy'}, {'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
{'name': '기타', 'color': '#9E9E9E', 'icon': 'category'}, {'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
]; ];
for (final category in defaultCategories) { for (final category in defaultCategories) {
@@ -116,4 +117,57 @@ class CategoryProvider extends ChangeNotifier {
return null; return null;
} }
} }
// 카테고리 이름을 현재 언어에 맞게 반환
String getLocalizedCategoryName(BuildContext context, String categoryKey) {
final localizations = AppLocalizations.of(context);
switch (categoryKey) {
case 'music':
return localizations.categoryMusic;
case 'ottVideo':
return localizations.categoryOttVideo;
case 'storageCloud':
return localizations.categoryStorageCloud;
case 'telecomInternetTv':
return localizations.categoryTelecomInternetTv;
case 'lifestyle':
return localizations.categoryLifestyle;
case 'shoppingEcommerce':
return localizations.categoryShoppingEcommerce;
case 'programming':
return localizations.categoryProgramming;
case 'collaborationOffice':
return localizations.categoryCollaborationOffice;
case 'aiService':
return localizations.categoryAiService;
case 'other':
return localizations.categoryOther;
default:
// 이전 버전과의 호환성을 위해 한국어 카테고리 이름도 처리
switch (categoryKey) {
case '음악':
return localizations.categoryMusic;
case 'OTT(동영상)':
return localizations.categoryOttVideo;
case '저장/클라우드':
return localizations.categoryStorageCloud;
case '통신 · 인터넷 · TV':
return localizations.categoryTelecomInternetTv;
case '생활/라이프스타일':
return localizations.categoryLifestyle;
case '쇼핑/이커머스':
return localizations.categoryShoppingEcommerce;
case '프로그래밍':
return localizations.categoryProgramming;
case '협업/오피스':
return localizations.categoryCollaborationOffice;
case 'AI 서비스':
return localizations.categoryAiService;
case '기타':
return localizations.categoryOther;
default:
return categoryKey;
}
}
}
} }

View File

@@ -1,22 +1,48 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'dart:ui' as ui;
class LocaleProvider extends ChangeNotifier { class LocaleProvider extends ChangeNotifier {
late Box<String> _localeBox; late Box<String> _localeBox;
Locale _locale = const Locale('ko'); Locale _locale = const Locale('ko');
static const List<String> supportedLanguages = ['en', 'ko', 'ja', 'zh'];
Locale get locale => _locale; Locale get locale => _locale;
Future<void> init() async { Future<void> init() async {
_localeBox = await Hive.openBox<String>('locale'); _localeBox = await Hive.openBox<String>('locale');
final savedLocale = _localeBox.get('locale', defaultValue: 'ko');
_locale = Locale(savedLocale ?? 'ko'); // 저장된 언어 설정 확인
final savedLocale = _localeBox.get('locale');
if (savedLocale != null) {
// 저장된 언어가 있으면 사용
_locale = Locale(savedLocale);
} else {
// 저장된 언어가 없으면 시스템 언어 감지
final systemLocale = ui.PlatformDispatcher.instance.locale;
// 시스템 언어가 지원되는 언어인지 확인
if (supportedLanguages.contains(systemLocale.languageCode)) {
_locale = Locale(systemLocale.languageCode);
} else {
// 지원되지 않는 언어면 영어 사용
_locale = const Locale('en');
}
// 감지된 언어 저장
await _localeBox.put('locale', _locale.languageCode);
}
notifyListeners(); notifyListeners();
} }
Future<void> setLocale(String languageCode) async { Future<void> setLocale(String languageCode) async {
if (_locale.languageCode != languageCode) {
_locale = Locale(languageCode); _locale = Locale(languageCode);
await _localeBox.put('locale', languageCode); await _localeBox.put('locale', languageCode);
notifyListeners(); notifyListeners();
} }
} }
}

View File

@@ -4,7 +4,7 @@ class NavigationProvider extends ChangeNotifier {
int _currentIndex = 0; int _currentIndex = 0;
final List<int> _navigationHistory = [0]; final List<int> _navigationHistory = [0];
String _currentRoute = '/'; String _currentRoute = '/';
String _currentTitle = ''; String _currentTitle = 'home';
int get currentIndex => _currentIndex; int get currentIndex => _currentIndex;
List<int> get navigationHistory => List.unmodifiable(_navigationHistory); List<int> get navigationHistory => List.unmodifiable(_navigationHistory);
@@ -28,10 +28,10 @@ class NavigationProvider extends ChangeNotifier {
}; };
static const Map<int, String> indexToTitle = { static const Map<int, String> indexToTitle = {
0: '', 0: 'home',
1: '분석', 1: 'analysis',
3: 'SMS 스캔', 3: 'smsScanLabel',
4: '설정', 4: 'settings',
}; };
void updateCurrentIndex(int index, {bool addToHistory = true}) { void updateCurrentIndex(int index, {bool addToHistory = true}) {
@@ -39,7 +39,7 @@ class NavigationProvider extends ChangeNotifier {
_currentIndex = index; _currentIndex = index;
_currentRoute = indexToRoute[index] ?? '/'; _currentRoute = indexToRoute[index] ?? '/';
_currentTitle = indexToTitle[index] ?? ''; _currentTitle = indexToTitle[index] ?? 'home';
if (addToHistory && index >= 0) { if (addToHistory && index >= 0) {
_navigationHistory.add(index); _navigationHistory.add(index);
@@ -57,17 +57,17 @@ class NavigationProvider extends ChangeNotifier {
if (index >= 0) { if (index >= 0) {
_currentIndex = index; _currentIndex = index;
_currentTitle = indexToTitle[index] ?? ''; _currentTitle = indexToTitle[index] ?? 'home';
} else { } else {
switch (route) { switch (route) {
case '/add-subscription': case '/add-subscription':
_currentTitle = '구독 추가'; _currentTitle = 'addSubscription';
break; break;
case '/subscription-detail': case '/subscription-detail':
_currentTitle = '구독 상세'; _currentTitle = 'subscriptionDetail';
break; break;
default: default:
_currentTitle = ''; _currentTitle = 'home';
} }
} }
@@ -89,7 +89,7 @@ class NavigationProvider extends ChangeNotifier {
void reset() { void reset() {
_currentIndex = 0; _currentIndex = 0;
_currentRoute = '/'; _currentRoute = '/';
_currentTitle = ''; _currentTitle = 'home';
_navigationHistory.clear(); _navigationHistory.clear();
_navigationHistory.add(0); _navigationHistory.add(0);
notifyListeners(); notifyListeners();
@@ -98,7 +98,7 @@ class NavigationProvider extends ChangeNotifier {
void clearHistoryAndGoHome() { void clearHistoryAndGoHome() {
_currentIndex = 0; _currentIndex = 0;
_currentRoute = '/'; _currentRoute = '/';
_currentTitle = ''; _currentTitle = 'home';
_navigationHistory.clear(); _navigationHistory.clear();
_navigationHistory.add(0); _navigationHistory.add(0);
notifyListeners(); notifyListeners();

View File

@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart'; import '../services/exchange_rate_service.dart';
import '../services/currency_util.dart';
import 'category_provider.dart'; import 'category_provider.dart';
class SubscriptionProvider extends ChangeNotifier { class SubscriptionProvider extends ChangeNotifier {
@@ -20,16 +21,23 @@ class SubscriptionProvider extends ChangeNotifier {
final rate = exchangeRateService.cachedUsdToKrwRate ?? final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
return _subscriptions.fold( final total = _subscriptions.fold(
0.0, 0.0,
(sum, subscription) { (sum, subscription) {
final price = subscription.currentPrice; final price = subscription.currentPrice;
if (subscription.currency == 'USD') { if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$${price} ×$rate = ₩${price * rate}');
return sum + (price * rate); return sum + (price * rate);
} }
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
return sum + price; return sum + price;
}, },
); );
debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: '
'${_subscriptions.length}개 구독, 총액 ₩$total');
return total;
} }
/// 월간 총 비용을 반환합니다. /// 월간 총 비용을 반환합니다.
@@ -81,6 +89,11 @@ class SubscriptionProvider extends ChangeNotifier {
try { try {
_subscriptions = _subscriptionBox.values.toList() _subscriptions = _subscriptionBox.values.toList()
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate)); ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: '
'${_subscriptions.length}개 구독, '
'총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('구독 목록 새로고침 중 오류 발생: $e'); debugPrint('구독 목록 새로고침 중 오류 발생: $e');
@@ -138,9 +151,13 @@ class SubscriptionProvider extends ChangeNotifier {
Future<void> updateSubscription(SubscriptionModel subscription) async { Future<void> updateSubscription(SubscriptionModel subscription) async {
try { try {
notifyListeners(); debugPrint('[SubscriptionProvider] updateSubscription 호출됨: '
'${subscription.serviceName}, '
'금액: ${subscription.monthlyCost} ${subscription.currency}, '
'현재가격: ${subscription.currentPrice} ${subscription.currency}');
await _subscriptionBox.put(subscription.id, subscription); await _subscriptionBox.put(subscription.id, subscription);
debugPrint('[SubscriptionProvider] Hive에 저장 완료');
// 이벤트 관련 알림 업데이트 // 이벤트 관련 알림 업데이트
if (subscription.isEventActive && subscription.eventEndDate != null) { if (subscription.isEventActive && subscription.eventEndDate != null) {
@@ -154,6 +171,8 @@ class SubscriptionProvider extends ChangeNotifier {
await refreshSubscriptions(); await refreshSubscriptions();
debugPrint('[SubscriptionProvider] 구독 업데이트 완료, '
'현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('구독 업데이트 중 오류 발생: $e'); debugPrint('구독 업데이트 중 오류 발생: $e');
@@ -230,17 +249,59 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
/// 총 월간 지출을 계산합니다. /// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense() async { Future<double> calculateTotalExpense({String? locale}) async {
// 이미 존재하는 totalMonthlyExpense getter를 사용 if (_subscriptions.isEmpty) return 0.0;
return totalMonthlyExpense;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null
? CurrencyUtil.getDefaultCurrency(locale)
: 'KRW'; // 기본값
double total = 0.0;
for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice;
if (subscription.currency == targetCurrency) {
// 이미 타겟 통화인 경우
total += currentPrice;
} else if (subscription.currency == 'USD') {
// USD를 타겟 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(currentPrice, targetCurrency);
total += converted ?? currentPrice;
} else if (targetCurrency == 'USD') {
// 타겟이 USD인 경우 다른 통화를 USD로 변환
final converted = await ExchangeRateService().convertTargetToUsd(currentPrice, subscription.currency);
total += converted ?? currentPrice;
} else {
// USD를 거쳐서 변환 (예: KRW → USD → JPY)
// 1단계: 구독 통화를 USD로 변환
final usdAmount = await ExchangeRateService().convertTargetToUsd(currentPrice, subscription.currency);
if (usdAmount != null) {
// 2단계: USD를 타겟 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(usdAmount, targetCurrency);
total += converted ?? currentPrice;
} else {
// 변환 실패 시 원래 값 사용
total += currentPrice;
}
}
} }
/// 최근 6개월의 월별 지출 데이터를 반환합니다. return total;
Future<List<Map<String, dynamic>>> getMonthlyExpenseData() async { }
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({String? locale}) async {
final now = DateTime.now(); final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = []; final List<Map<String, dynamic>> monthlyData = [];
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null
? CurrencyUtil.getDefaultCurrency(locale)
: 'KRW'; // 기본값
// 최근 6개월 데이터 생성 // 최근 6개월 데이터 생성
for (int i = 5; i >= 0; i--) { for (int i = 5; i >= 0; i--) {
final month = DateTime(now.year, now.month - i, 1); final month = DateTime(now.year, now.month - i, 1);
@@ -256,14 +317,38 @@ class SubscriptionProvider extends ChangeNotifier {
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) && if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) { subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려) // 해당 월의 비용 계산 (이벤트 가격 고려)
double cost;
if (subscription.isEventActive && if (subscription.isEventActive &&
subscription.eventStartDate != null && subscription.eventStartDate != null &&
subscription.eventEndDate != null && subscription.eventEndDate != null &&
month.isAfter(subscription.eventStartDate!) && month.isAfter(subscription.eventStartDate!) &&
month.isBefore(subscription.eventEndDate!)) { month.isBefore(subscription.eventEndDate!)) {
monthTotal += subscription.eventPrice ?? subscription.monthlyCost; cost = subscription.eventPrice ?? subscription.monthlyCost;
} else { } else {
monthTotal += subscription.monthlyCost; cost = subscription.monthlyCost;
}
// 통화 변환
if (subscription.currency == targetCurrency) {
monthTotal += cost;
} else if (subscription.currency == 'USD') {
final converted = await ExchangeRateService().convertUsdToTarget(cost, targetCurrency);
monthTotal += converted ?? cost;
} else if (targetCurrency == 'USD') {
final converted = await ExchangeRateService().convertTargetToUsd(cost, subscription.currency);
monthTotal += converted ?? cost;
} else {
// USD를 거쳐서 변환 (예: KRW → USD → JPY)
// 1단계: 구독 통화를 USD로 변환
final usdAmount = await ExchangeRateService().convertTargetToUsd(cost, subscription.currency);
if (usdAmount != null) {
// 2단계: USD를 타겟 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(usdAmount, targetCurrency);
monthTotal += converted ?? cost;
} else {
// 변환 실패 시 원래 값 사용
monthTotal += cost;
}
} }
} }
} }
@@ -347,7 +432,7 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('플로') || serviceName.contains('플로') ||
serviceName.contains('벡스')) { serviceName.contains('벡스')) {
categoryId = categories.firstWhere( categoryId = categories.firstWhere(
(cat) => cat.name == '음악 서비스', (cat) => cat.name == 'music',
orElse: () => categories.first, orElse: () => categories.first,
).id; ).id;
} }
@@ -357,7 +442,7 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('midjourney') || serviceName.contains('midjourney') ||
serviceName.contains('copilot')) { serviceName.contains('copilot')) {
categoryId = categories.firstWhere( categoryId = categories.firstWhere(
(cat) => cat.name == 'AI 서비스', (cat) => cat.name == 'aiService',
orElse: () => categories.first, orElse: () => categories.first,
).id; ).id;
} }
@@ -367,7 +452,7 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('webstorm') || serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) { serviceName.contains('jetbrains')) {
categoryId = categories.firstWhere( categoryId = categories.firstWhere(
(cat) => cat.name == '프로그래밍/개발', (cat) => cat.name == 'programming',
orElse: () => categories.first, orElse: () => categories.first,
).id; ).id;
} }
@@ -380,14 +465,14 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('icloud') || serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) { serviceName.contains('아이클라우드')) {
categoryId = categories.firstWhere( categoryId = categories.firstWhere(
(cat) => cat.name == '오피스/협업 툴', (cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first, orElse: () => categories.first,
).id; ).id;
} }
// 기타 서비스 (기본값) // 기타 서비스 (기본값)
else { else {
categoryId = categories.firstWhere( categoryId = categories.firstWhere(
(cat) => cat.name == '기타 서비스', (cat) => cat.name == 'other',
orElse: () => categories.first, orElse: () => categories.first,
).id; ).id;
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../widgets/analysis/analysis_screen_spacer.dart'; import '../widgets/analysis/analysis_screen_spacer.dart';
import '../widgets/analysis/subscription_pie_chart_card.dart'; import '../widgets/analysis/subscription_pie_chart_card.dart';
@@ -22,8 +23,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
double _totalExpense = 0; double _totalExpense = 0;
List<Map<String, dynamic>> _monthlyData = []; List<Map<String, dynamic>> _monthlyData = [];
int _touchedIndex = -1;
bool _isLoading = true; bool _isLoading = true;
String _lastDataHash = '';
@override @override
void initState() { void initState() {
@@ -36,6 +37,23 @@ class _AnalysisScreenState extends State<AnalysisScreen>
_loadData(); _loadData();
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider);
debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) {
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
_loadData();
}
}
@override @override
void dispose() { void dispose() {
_animationController.dispose(); _animationController.dispose();
@@ -43,24 +61,50 @@ class _AnalysisScreenState extends State<AnalysisScreen>
super.dispose(); super.dispose();
} }
/// 구독 데이터의 해시값을 계산하여 변경 감지
String _calculateDataHash(SubscriptionProvider provider) {
final subscriptions = provider.subscriptions;
final buffer = StringBuffer();
buffer.write(subscriptions.length);
buffer.write('_');
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
for (final sub in subscriptions) {
buffer.write('_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
}
return buffer.toString();
}
Future<void> _loadData() async { Future<void> _loadData() async {
debugPrint('[AnalysisScreen] _loadData 호출됨');
setState(() { setState(() {
_isLoading = true; _isLoading = true;
}); });
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode;
// 총 지출 계산 // 총 지출 계산 (로케일별 기본 통화로 환산)
_totalExpense = await provider.calculateTotalExpense(); _totalExpense = await provider.calculateTotalExpense(locale: locale);
debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
// 월별 데이터 계산 // 월별 데이터 계산 (로케일별 기본 통화로 환산)
_monthlyData = await provider.getMonthlyExpenseData(); _monthlyData = await provider.getMonthlyExpenseData(locale: locale);
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
// 현재 데이터 해시값 저장
_lastDataHash = _calculateDataHash(provider);
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
// 데이터 로드 완료 후 애니메이션 시작 // 데이터 로드 완료 후 애니메이션 시작
_animationController.reset();
_animationController.forward(); _animationController.forward();
} }
@@ -85,8 +129,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<SubscriptionProvider>( // Provider를 직접 사용하여 변경 감지
builder: (context, provider, child) { final provider = Provider.of<SubscriptionProvider>(context);
final subscriptions = provider.subscriptions; final subscriptions = provider.subscriptions;
if (_isLoading) { if (_isLoading) {
@@ -116,14 +160,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
SubscriptionPieChartCard( SubscriptionPieChartCard(
subscriptions: subscriptions, subscriptions: subscriptions,
animationController: _animationController, animationController: _animationController,
touchedIndex: _touchedIndex,
onPieTouch: (index) => setState(() => _touchedIndex = index),
), ),
const AnalysisScreenSpacer(), const AnalysisScreenSpacer(),
// 2. 총 지출 요약 카드 // 2. 총 지출 요약 카드
TotalExpenseSummaryCard( TotalExpenseSummaryCard(
key: ValueKey('total_expense_${_lastDataHash}'),
subscriptions: subscriptions, subscriptions: subscriptions,
totalExpense: _totalExpense, totalExpense: _totalExpense,
animationController: _animationController, animationController: _animationController,
@@ -133,6 +176,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 3. 월별 지출 차트 // 3. 월별 지출 차트
MonthlyExpenseChartCard( MonthlyExpenseChartCard(
key: ValueKey('monthly_expense_${_lastDataHash}'),
monthlyData: _monthlyData, monthlyData: _monthlyData,
animationController: _animationController, animationController: _animationController,
), ),
@@ -152,7 +196,5 @@ class _AnalysisScreenState extends State<AnalysisScreen>
), ),
], ],
); );
},
);
} }
} }

View File

@@ -2,6 +2,7 @@ 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';
class CategoryManagementScreen extends StatefulWidget { class CategoryManagementScreen extends StatefulWidget {
const CategoryManagementScreen({super.key}); const CategoryManagementScreen({super.key});
@@ -89,15 +90,15 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
), ),
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: '#1976D2', child: Text('파란색', style: TextStyle(color: AppColors.darkNavy))), value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', child: Text('초록색', style: TextStyle(color: AppColors.darkNavy))), value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', child: Text('주황색', style: TextStyle(color: AppColors.darkNavy))), value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', child: Text('빨간색', style: TextStyle(color: AppColors.darkNavy))), value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', child: Text('보라색', style: TextStyle(color: AppColors.darkNavy))), value: '#9C27B0', child: Text(AppLocalizations.of(context).colorPurple, style: TextStyle(color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -162,7 +163,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
int.parse(category.color.replaceAll('#', '0xFF'))), int.parse(category.color.replaceAll('#', '0xFF'))),
), ),
title: Text( title: Text(
category.name, provider.getLocalizedCategoryName(context, category.name),
style: TextStyle( style: TextStyle(
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),

View File

@@ -8,6 +8,7 @@ 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';
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면 /// 구독 상세 정보를 표시하고 편집할 수 있는 화면
class DetailScreen extends StatefulWidget { class DetailScreen extends StatefulWidget {
@@ -100,7 +101,7 @@ class _DetailScreenState extends State<DetailScreen>
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'편집 모드', AppLocalizations.of(context).editMode,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -109,7 +110,7 @@ class _DetailScreenState extends State<DetailScreen>
), ),
const Spacer(), const Spacer(),
Text( Text(
'변경사항은 저장 후 적용됩니다', AppLocalizations.of(context).changesAppliedAfterSave,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,

View File

@@ -13,6 +13,7 @@ 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/glassmorphic_scaffold.dart';
import '../widgets/home_content.dart'; import '../widgets/home_content.dart';
import '../l10n/app_localizations.dart';
class MainScreen extends StatefulWidget { class MainScreen extends StatefulWidget {
const MainScreen({super.key}); const MainScreen({super.key});
@@ -162,17 +163,17 @@ class _MainScreenState extends State<MainScreen>
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: const Row( content: Row(
children: [ children: [
Icon( const Icon(
Icons.check_circle, Icons.check_circle,
color: AppColors.pureWhite, color: AppColors.pureWhite,
size: 20, size: 20,
), ),
SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
'구독이 추가되었습니다', AppLocalizations.of(context).subscriptionAdded,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.pureWhite, color: AppColors.pureWhite,

View File

@@ -5,12 +5,13 @@ 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 '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart'; import '../theme/adaptive_theme.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 '../providers/locale_provider.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -83,6 +84,99 @@ class SettingsScreen extends StatelessWidget {
// 광고 위젯 추가 // 광고 위젯 추가
const NativeAdWidget(key: ValueKey('settings_ad')), const NativeAdWidget(key: ValueKey('settings_ad')),
const SizedBox(height: 16), const SizedBox(height: 16),
// 언어 설정
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
child: Consumer<LocaleProvider>(
builder: (context, localeProvider, child) {
final loc = AppLocalizations.of(context);
return ListTile(
title: Text(
loc.language,
style: const TextStyle(color: AppColors.textPrimary),
),
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:
AppColors.textSecondary.withValues(alpha: 0.5),
),
),
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: [
DropdownMenuItem(
value: 'ko',
child: Text(
loc.korean,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem(
value: 'en',
child: Text(
loc.english,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem(
value: 'ja',
child: Text(
loc.japanese,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem(
value: 'zh',
child: Text(
loc.chinese,
style: const TextStyle(color: Colors.white),
),
),
],
onChanged: (String? value) {
if (value != null) {
localeProvider.setLocale(value);
}
},
),
),
);
},
),
),
// 앱 잠금 설정 UI 숨김 // 앱 잠금 설정 UI 숨김
// Card( // Card(
// margin: const EdgeInsets.all(16), // margin: const EdgeInsets.all(16),
@@ -116,13 +210,16 @@ class SettingsScreen extends StatelessWidget {
return Column( return Column(
children: [ children: [
ListTile( ListTile(
title: const Text( title: Text(
'알림 권한', AppLocalizations.of(context).notificationPermission,
style: TextStyle(color: AppColors.textPrimary), style:
const TextStyle(color: AppColors.textPrimary),
), ),
subtitle: const Text( subtitle: Text(
'알림을 받으려면 권한이 필요합니다', AppLocalizations.of(context)
style: TextStyle(color: AppColors.textSecondary), .notificationPermissionDesc,
style:
const TextStyle(color: AppColors.textSecondary),
), ),
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: () async { onPressed: () async {
@@ -133,23 +230,28 @@ class SettingsScreen extends StatelessWidget {
} else { } else {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '알림 권한이 거부되었습니다', message: AppLocalizations.of(context)
.notificationPermissionDenied,
); );
} }
}, },
child: const Text('권한 요청'), child: Text(
AppLocalizations.of(context).requestPermission),
), ),
), ),
const Divider(), const Divider(),
// 결제 예정 알림 기본 스위치 // 결제 예정 알림 기본 스위치
SwitchListTile( SwitchListTile(
title: const Text( title: Text(
'결제 예정 알림', AppLocalizations.of(context).paymentNotification,
style: TextStyle(color: AppColors.textPrimary), style:
const TextStyle(color: AppColors.textPrimary),
), ),
subtitle: const Text( subtitle: Text(
'결제 예정일 알림 받기', AppLocalizations.of(context)
style: TextStyle(color: AppColors.textSecondary), .paymentNotificationDesc,
style:
const TextStyle(color: AppColors.textSecondary),
), ),
value: provider.isPaymentEnabled, value: provider.isPaymentEnabled,
onChanged: (value) { onChanged: (value) {
@@ -181,8 +283,10 @@ class SettingsScreen extends StatelessWidget {
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
// 알림 시점 선택 (1일전, 2일전, 3일전) // 알림 시점 선택 (1일전, 2일전, 3일전)
const Text('알림 시점', Text(
style: TextStyle( AppLocalizations.of(context)
.notificationTiming,
style: const TextStyle(
fontWeight: FontWeight.bold)), fontWeight: FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
Padding( Padding(
@@ -192,12 +296,24 @@ class SettingsScreen extends StatelessWidget {
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.spaceEvenly, MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildReminderDayRadio(context, _buildReminderDayRadio(
provider, 1, '1일 전'), context,
_buildReminderDayRadio(context, provider,
provider, 2, '2일 전'), 1,
_buildReminderDayRadio(context, AppLocalizations.of(context)
provider, 3, '3일 전'), .oneDayBefore),
_buildReminderDayRadio(
context,
provider,
2,
AppLocalizations.of(context)
.twoDaysBefore),
_buildReminderDayRadio(
context,
provider,
3,
AppLocalizations.of(context)
.threeDaysBefore),
], ],
), ),
), ),
@@ -205,8 +321,10 @@ class SettingsScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
// 알림 시간 선택 // 알림 시간 선택
const Text('알림 시간', Text(
style: TextStyle( AppLocalizations.of(context)
.notificationTime,
style: const TextStyle(
fontWeight: FontWeight.bold)), fontWeight: FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
InkWell( InkWell(
@@ -304,13 +422,21 @@ class SettingsScreen extends StatelessWidget {
const EdgeInsets const EdgeInsets
.symmetric( .symmetric(
horizontal: 12), horizontal: 12),
title: title: Text(
const Text('1일마다 반복 알림'), AppLocalizations.of(
context)
.dailyReminder),
subtitle: Text( subtitle: Text(
provider.isDailyReminderEnabled provider.isDailyReminderEnabled
? '결제일까지 매일 알림을 받습니다' ? AppLocalizations.of(
: '결제 ${provider.reminderDays}일 전에 알림을 받습니다', context)
style: TextStyle( .dailyReminderEnabled
: AppLocalizations.of(
context)
.dailyReminderDisabledWithDays(
provider
.reminderDays),
style: const TextStyle(
color: AppColors color: AppColors
.textLight), .textLight),
), ),
@@ -355,13 +481,13 @@ class SettingsScreen extends StatelessWidget {
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: ListTile( child: ListTile(
title: const Text( title: Text(
'앱 정보', AppLocalizations.of(context).appInfo,
style: TextStyle(color: AppColors.textPrimary), style: const TextStyle(color: AppColors.textPrimary),
), ),
subtitle: const Text( subtitle: Text(
'버전 1.0.0', '${AppLocalizations.of(context).version} 1.0.0',
style: TextStyle(color: AppColors.textSecondary), style: const TextStyle(color: AppColors.textSecondary),
), ),
leading: const Icon( leading: const Icon(
Icons.info, Icons.info,
@@ -372,13 +498,14 @@ class SettingsScreen extends StatelessWidget {
if (kIsWeb) { if (kIsWeb) {
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'Digital Rent Manager', applicationName: AppLocalizations.of(context).appTitle,
applicationVersion: '1.0.0', applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50), applicationIcon: const FlutterLogo(size: 50),
children: [ children: [
const Text('디지털 월세 관리 앱'), Text(AppLocalizations.of(context).appDescription),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('개발자: Julian Sul'), Text(
'${AppLocalizations.of(context).developer}: Julian Sul'),
], ],
); );
return; return;
@@ -407,7 +534,8 @@ class SettingsScreen extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '스토어를 열 수 없습니다', message:
AppLocalizations.of(context).cannotOpenStore,
); );
} }
} }
@@ -415,13 +543,14 @@ class SettingsScreen extends StatelessWidget {
// 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시 // 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'SubManager', applicationName: AppLocalizations.of(context).appTitle,
applicationVersion: '1.0.0', applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50), applicationIcon: const FlutterLogo(size: 50),
children: [ children: [
const Text('구독 관리 앱'), Text(AppLocalizations.of(context).appDescription),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('개발자: SubManager Team'), Text(
'${AppLocalizations.of(context).developer}: Julian Sul'),
], ],
); );
} }
@@ -438,30 +567,4 @@ class SettingsScreen extends StatelessWidget {
], ],
); );
} }
String _getThemeModeText(AppThemeMode mode) {
switch (mode) {
case AppThemeMode.light:
return '라이트';
case AppThemeMode.dark:
return '다크';
case AppThemeMode.oled:
return 'OLED 블랙';
case AppThemeMode.system:
return '시스템 설정';
}
}
IconData _getThemeModeIcon(AppThemeMode mode) {
switch (mode) {
case AppThemeMode.light:
return Icons.light_mode;
case AppThemeMode.dark:
return Icons.dark_mode;
case AppThemeMode.oled:
return Icons.phonelink_lock;
case AppThemeMode.system:
return Icons.settings_brightness;
}
}
} }

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../providers/locale_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/subscription.dart'; import '../models/subscription.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가 import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
import '../widgets/glassmorphism_card.dart'; import '../widgets/glassmorphism_card.dart';
import '../widgets/themed_text.dart'; import '../widgets/themed_text.dart';
@@ -18,6 +20,7 @@ import '../providers/category_provider.dart';
import '../models/category_model.dart'; import '../models/category_model.dart';
import '../widgets/common/form_fields/category_selector.dart'; import '../widgets/common/form_fields/category_selector.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../l10n/app_localizations.dart';
class SmsScanScreen extends StatefulWidget { class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key}); const SmsScanScreen({super.key});
@@ -75,7 +78,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (scannedSubscriptionModels.isEmpty) { if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음'); print('스캔된 구독이 없음');
setState(() { setState(() {
_errorMessage = '구독 정보를 찾을 수 없습니다.'; _errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -98,7 +101,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (repeatSubscriptions.isEmpty) { if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음'); print('반복 결제된 구독이 없음');
setState(() { setState(() {
_errorMessage = '반복 결제된 구독 정보를 찾을 수 없습니다.'; _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -131,7 +134,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (mounted) { if (mounted) {
AppSnackBar.showInfo( AppSnackBar.showInfo(
context: context, context: context,
message: '신규 구독 관련 SMS를 찾을 수 없습니다', message: AppLocalizations.of(context).newSubscriptionNotFound,
icon: Icons.search_off_rounded, icon: Icons.search_off_rounded,
); );
} }
@@ -148,7 +151,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
print('SMS 스캔 중 오류 발생: $e'); print('SMS 스캔 중 오류 발생: $e');
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = 'SMS 스캔 중 오류가 발생했습니다: $e'; _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_isLoading = false; _isLoading = false;
}); });
} }
@@ -389,7 +392,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (mounted) { if (mounted) {
AppSnackBar.showSuccess( AppSnackBar.showSuccess(
context: context, context: context,
message: '${subscription.serviceName} 구독이 추가되었습니다.', message: AppLocalizations.of(context).subscriptionAddedWithName(subscription.serviceName),
); );
} }
@@ -400,7 +403,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (mounted) { if (mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '구독 추가 중 오류가 발생했습니다: $e', message: AppLocalizations.of(context).subscriptionAddErrorWithMessage(e.toString()),
); );
// 오류가 있어도 다음 구독으로 이동 // 오류가 있어도 다음 구독으로 이동
@@ -416,7 +419,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (mounted) { if (mounted) {
AppSnackBar.showInfo( AppSnackBar.showInfo(
context: context, context: context,
message: '${subscription.serviceName} 구독을 건너뛰었습니다.', message: AppLocalizations.of(context).subscriptionSkipped(subscription.serviceName),
icon: Icons.skip_next_rounded, icon: Icons.skip_next_rounded,
); );
} }
@@ -447,7 +450,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 완료 메시지 표시 // 완료 메시지 표시
AppSnackBar.showSuccess( AppSnackBar.showSuccess(
context: context, context: context,
message: '모든 구독이 처리되었습니다.', message: AppLocalizations.of(context).allSubscriptionsProcessed,
); );
} }
@@ -482,7 +485,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
} }
final daysUntil = adjusted.difference(now).inDays; final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil 후)'; return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil);
} else if (subscription.billingCycle == '연간') { } else if (subscription.billingCycle == '연간') {
// 올해 또는 내년 같은 날짜 // 올해 또는 내년 같은 날짜
int day = date.day; int day = date.day;
@@ -503,14 +506,14 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
} }
final daysUntil = adjusted.difference(now).inDays; final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil 후)'; return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil);
} else { } else {
return '다음 결제일 확인 필요 (과거 날짜)'; return '다음 결제일 확인 필요 (과거 날짜)';
} }
} else { } else {
// 미래 날짜인 경우 // 미래 날짜인 경우
final daysUntil = date.difference(now).inDays; final daysUntil = date.difference(now).inDays;
return '다음 결제일: ${_formatDate(date)} ($daysUntil 후)'; return AppLocalizations.of(context).nextBillingDateInfo(AppLocalizations.of(context).formatDate(date), daysUntil);
} }
} }
@@ -521,7 +524,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 결제 반복 횟수 텍스트 // 결제 반복 횟수 텍스트
String _getRepeatCountText(int count) { String _getRepeatCountText(int count) {
return '$count회 결제 감지됨'; return AppLocalizations.of(context).repeatCountDetected(count);
} }
// 카테고리 칩 빌드 // 카테고리 칩 빌드
@@ -532,7 +535,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 카테고리가 없으면 기타 카테고리 찾기 // 카테고리가 없으면 기타 카테고리 찾기
final defaultCategory = category ?? categoryProvider.categories.firstWhere( final defaultCategory = category ?? categoryProvider.categories.firstWhere(
(cat) => cat.name == '기타', (cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first, orElse: () => categoryProvider.categories.first,
); );
@@ -553,7 +556,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
ThemedText( ThemedText(
defaultCategory.name, categoryProvider.getLocalizedCategoryName(context, defaultCategory.name),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
forceDark: true, forceDark: true,
@@ -567,25 +570,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 카테고리 아이콘 반환 // 카테고리 아이콘 반환
IconData _getCategoryIcon(CategoryModel category) { IconData _getCategoryIcon(CategoryModel category) {
switch (category.name) { switch (category.name) {
case '음악': case 'music':
return Icons.music_note_rounded; return Icons.music_note_rounded;
case 'OTT(동영상)': case 'ottVideo':
return Icons.movie_filter_rounded; return Icons.movie_filter_rounded;
case '저장/클라우드': case 'storageCloud':
return Icons.cloud_outlined; return Icons.cloud_outlined;
case '통신 · 인터넷 · TV': case 'telecomInternetTv':
return Icons.wifi_rounded; return Icons.wifi_rounded;
case '생활/라이프스타일': case 'lifestyle':
return Icons.home_outlined; return Icons.home_outlined;
case '쇼핑/이커머스': case 'shoppingEcommerce':
return Icons.shopping_cart_outlined; return Icons.shopping_cart_outlined;
case '프로그래밍': case 'programming':
return Icons.code_rounded; return Icons.code_rounded;
case '협업/오피스': case 'collaborationOffice':
return Icons.business_center_outlined; return Icons.business_center_outlined;
case 'AI 서비스': case 'aiService':
return Icons.smart_toy_outlined; return Icons.smart_toy_outlined;
case '기타': case 'other':
default: default:
return Icons.category_outlined; return Icons.category_outlined;
} }
@@ -595,7 +598,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
String _getDefaultCategoryId() { String _getDefaultCategoryId() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final otherCategory = categoryProvider.categories.firstWhere( final otherCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name == '기타', (cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first, // 만약 "기타"가 없으면 첫 번째 카테고리 orElse: () => categoryProvider.categories.first, // 만약 "기타"가 없으면 첫 번째 카테고리
); );
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
@@ -638,9 +641,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor), valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const ThemedText('SMS 메시지를 스캔 중입니다...', forceDark: true), ThemedText(AppLocalizations.of(context).scanningMessages, forceDark: true),
const SizedBox(height: 8), const SizedBox(height: 8),
const ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7, forceDark: true), ThemedText(AppLocalizations.of(context).findingSubscriptions, opacity: 0.7, forceDark: true),
], ],
), ),
), ),
@@ -668,17 +671,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const ThemedText( ThemedText(
'2회 이상 결제된 구독 서비스 찾기', AppLocalizations.of(context).findRepeatSubscriptions,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true, forceDark: true,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ThemedText( child: ThemedText(
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.', AppLocalizations.of(context).scanTextMessages,
textAlign: TextAlign.center, textAlign: TextAlign.center,
opacity: 0.7, opacity: 0.7,
forceDark: true, forceDark: true,
@@ -686,7 +689,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
PrimaryButton( PrimaryButton(
text: '스캔 시작하기', text: AppLocalizations.of(context).startScanning,
icon: Icons.search_rounded, icon: Icons.search_rounded,
onPressed: _scanSms, onPressed: _scanSms,
width: 200, width: 200,
@@ -751,16 +754,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const ThemedText( ThemedText(
'다음 구독을 찾았습니다', AppLocalizations.of(context).foundSubscription,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true, forceDark: true,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// 서비스명 // 서비스명
const ThemedText( ThemedText(
'서비스명', AppLocalizations.of(context).serviceName,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true, forceDark: true,
@@ -781,28 +784,28 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const ThemedText( ThemedText(
'월 비용', AppLocalizations.of(context).monthlyCost,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true, forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( // 언어별 통화 표시
subscription.currency == 'USD' FutureBuilder<String>(
? NumberFormat.currency( future: CurrencyUtil.formatAmountWithLocale(
locale: 'en_US', subscription.monthlyCost,
symbol: '\$', subscription.currency,
decimalDigits: 2, context.read<LocaleProvider>().locale.languageCode,
).format(subscription.monthlyCost) ),
: NumberFormat.currency( builder: (context, snapshot) {
locale: 'ko_KR', return ThemedText(
symbol: '', snapshot.data ?? '-',
decimalDigits: 0,
).format(subscription.monthlyCost),
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true, forceDark: true,
);
},
), ),
], ],
), ),
@@ -811,8 +814,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const ThemedText( ThemedText(
'결제 주기', AppLocalizations.of(context).billingCycle,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true, forceDark: true,
@@ -832,8 +835,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// 다음 결제일 // 다음 결제일
const ThemedText( ThemedText(
'다음 결제일', AppLocalizations.of(context).nextBillingDateLabel,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true, forceDark: true,
@@ -848,8 +851,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// 카테고리 선택 // 카테고리 선택
const ThemedText( ThemedText(
'카테고리', AppLocalizations.of(context).category,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true, forceDark: true,
@@ -877,8 +880,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 웹사이트 URL 입력 필드 추가/수정 // 웹사이트 URL 입력 필드 추가/수정
BaseTextField( BaseTextField(
controller: _websiteUrlController, controller: _websiteUrlController,
label: '웹사이트 URL (자동 추출됨)', label: AppLocalizations.of(context).websiteUrlAuto,
hintText: '웹사이트 URL을 수정하거나 비워두세요', hintText: AppLocalizations.of(context).websiteUrlHint,
prefixIcon: Icon( prefixIcon: Icon(
Icons.language, Icons.language,
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -895,7 +898,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
children: [ children: [
Expanded( Expanded(
child: SecondaryButton( child: SecondaryButton(
text: '건너뛰기', text: AppLocalizations.of(context).skip,
onPressed: _skipCurrentSubscription, onPressed: _skipCurrentSubscription,
height: 48, height: 48,
), ),
@@ -903,7 +906,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: PrimaryButton( child: PrimaryButton(
text: '추가하기', text: AppLocalizations.of(context).add,
onPressed: _addCurrentSubscription, onPressed: _addCurrentSubscription,
height: 48, height: 48,
), ),

View File

@@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../l10n/app_localizations.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@@ -323,7 +324,7 @@ class _SplashScreenState extends State<SplashScreen>
); );
}, },
child: Text( child: Text(
'Digital Rent Manager', AppLocalizations.of(context).appTitle,
style: TextStyle( style: TextStyle(
fontSize: 36, fontSize: 36,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -350,7 +351,7 @@ class _SplashScreenState extends State<SplashScreen>
); );
}, },
child: Text( child: Text(
'구독 서비스 관리를 더 쉽게', AppLocalizations.of(context).appSubtitle,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.primaryColor color: AppColors.primaryColor

View File

@@ -6,67 +6,166 @@ import 'exchange_rate_service.dart';
class CurrencyUtil { class CurrencyUtil {
static final ExchangeRateService _exchangeRateService = ExchangeRateService(); static final ExchangeRateService _exchangeRateService = ExchangeRateService();
/// 구독 목록의 총 월 비용을 계산 (원화로 환산, 이벤트 가격 반영) /// 언어에 따른 기본 통화 반환
static Future<double> calculateTotalMonthlyExpense( static String getDefaultCurrency(String locale) {
List<SubscriptionModel> subscriptions) async { switch (locale) {
case 'ko':
return 'KRW';
case 'ja':
return 'JPY';
case 'zh':
return 'CNY';
case 'en':
default:
return 'USD';
}
}
/// 언어에 따른 서브 통화 반환 (영어 제외 모두 USD)
static String? getSecondaryCurrency(String locale, String? selectedCurrency) {
if (locale == 'en' && selectedCurrency == 'KRW') {
return 'KRW';
}
return locale != 'en' ? 'USD' : null;
}
/// 통화 기호 반환
static String getCurrencySymbol(String currency) {
switch (currency) {
case 'KRW':
return '';
case 'USD':
return '\$';
case 'JPY':
return '¥';
case 'CNY':
return '¥';
default:
return currency;
}
}
/// 통화별 locale 반환
static String _getLocaleForCurrency(String currency) {
switch (currency) {
case 'KRW':
return 'ko_KR';
case 'USD':
return 'en_US';
case 'JPY':
return 'ja_JP';
case 'CNY':
return 'zh_CN';
default:
return 'en_US';
}
}
/// 단일 통화 포맷팅
static String _formatSingleCurrency(double amount, String currency) {
final locale = _getLocaleForCurrency(currency);
final symbol = getCurrencySymbol(currency);
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
return NumberFormat.currency(
locale: locale,
symbol: symbol,
decimalDigits: decimals,
).format(amount);
}
/// 금액 포맷팅 (기본 통화 + 서브 통화)
static Future<String> formatAmountWithLocale(
double amount,
String currency,
String locale,
) async {
final defaultCurrency = getDefaultCurrency(locale);
// 입력 통화가 기본 통화인 경우
if (currency == defaultCurrency) {
return _formatSingleCurrency(amount, currency);
}
// USD 입력인 경우 - 기본 통화로 변환하여 표시
if (currency == 'USD' && defaultCurrency != 'USD') {
final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency);
if (convertedAmount != null) {
final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency);
final usdFormatted = _formatSingleCurrency(amount, 'USD');
return '$primaryFormatted ($usdFormatted)';
}
}
// 영어 사용자가 KRW 선택한 경우
if (locale == 'en' && currency == 'KRW') {
return _formatSingleCurrency(amount, currency);
}
// 기타 통화 입력인 경우
return _formatSingleCurrency(amount, currency);
}
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
static Future<double> calculateTotalMonthlyExpenseInDefaultCurrency(
List<SubscriptionModel> subscriptions,
String locale,
) async {
final defaultCurrency = getDefaultCurrency(locale);
double total = 0.0; double total = 0.0;
for (var subscription in subscriptions) { for (var subscription in subscriptions) {
// 이벤트 가격이 있으면 currentPrice 사용
final price = subscription.currentPrice; final price = subscription.currentPrice;
if (subscription.currency == 'USD') { if (subscription.currency == defaultCurrency) {
// USD인 경우 KRW로 변환 // 기본 통화면 그대로 합산
final krwAmount = await _exchangeRateService
.convertUsdToKrw(price);
if (krwAmount != null) {
total += krwAmount;
}
} else {
// KRW인 경우 그대로 합산
total += price; total += price;
} else if (subscription.currency == 'USD') {
// USD면 기본 통화로 변환
final converted = await _exchangeRateService.convertUsdToTarget(price, defaultCurrency);
if (converted != null) {
total += converted;
}
} else if (defaultCurrency == 'USD') {
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
final converted = await _exchangeRateService.convertTargetToUsd(price, subscription.currency);
if (converted != null) {
total += converted;
}
} }
} }
return total; return total;
} }
/// 구독 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) /// 구독 목록의 총 월 비용을 계산 (원화로 환산, 이벤트 가격 반영) - 기존 호환성 유지
static Future<double> calculateTotalMonthlyExpense(
List<SubscriptionModel> subscriptions) async {
return calculateTotalMonthlyExpenseInDefaultCurrency(subscriptions, 'ko');
}
/// 구독의 월 비용을 표시 형식에 맞게 변환 (언어별 통화)
static Future<String> formatSubscriptionAmountWithLocale(
SubscriptionModel subscription, String locale) async {
final price = subscription.currentPrice;
return formatAmountWithLocale(price, subscription.currency, locale);
}
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
static Future<String> formatSubscriptionAmount( static Future<String> formatSubscriptionAmount(
SubscriptionModel subscription) async { SubscriptionModel subscription) async {
// 이벤트 가격이 있으면 currentPrice 사용 return formatSubscriptionAmountWithLocale(subscription, 'ko');
final price = subscription.currentPrice;
if (subscription.currency == 'USD') {
// USD 표시 + 원화 환산 금액
final usdFormatted = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(price);
// 원화 환산 금액
final krwAmount = await _exchangeRateService
.getFormattedKrwAmount(price);
return '$usdFormatted $krwAmount';
} else {
// 원화 표시
return NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(price);
}
} }
/// 총액을 화로 표시 /// 총액을 언어별 기본 통화로 표시
static String formatTotalAmountWithLocale(double amount, String locale) {
final defaultCurrency = getDefaultCurrency(locale);
return _formatSingleCurrency(amount, defaultCurrency);
}
/// 총액을 원화로 표시 - 기존 호환성 유지
static String formatTotalAmount(double amount) { static String formatTotalAmount(double amount) {
return NumberFormat.currency( return formatTotalAmountWithLocale(amount, 'ko');
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(amount);
} }
/// 환율 정보 텍스트 가져오기 /// 환율 정보 텍스트 가져오기
@@ -74,25 +173,34 @@ class CurrencyUtil {
return _exchangeRateService.getFormattedExchangeRateInfo(); return _exchangeRateService.getFormattedExchangeRateInfo();
} }
/// 이벤트로 인한 총 절약액 계산 (원화로 환산) /// 언어별 환율 정보 텍스트 가져오기
static Future<double> calculateTotalEventSavings( static Future<String> getExchangeRateInfoForLocale(String locale) {
List<SubscriptionModel> subscriptions) async { return _exchangeRateService.getFormattedExchangeRateInfoForLocale(locale);
}
/// 이벤트로 인한 총 절약액 계산 (언어별 기본 통화로)
static Future<double> calculateTotalEventSavingsInDefaultCurrency(
List<SubscriptionModel> subscriptions, String locale) async {
final defaultCurrency = getDefaultCurrency(locale);
double total = 0.0; double total = 0.0;
for (var subscription in subscriptions) { for (var subscription in subscriptions) {
if (subscription.isCurrentlyInEvent) { if (subscription.isCurrentlyInEvent) {
final savings = subscription.eventSavings; final savings = subscription.eventSavings;
if (subscription.currency == 'USD') { if (subscription.currency == defaultCurrency) {
// USD인 경우 KRW로 변환
final krwAmount = await _exchangeRateService
.convertUsdToKrw(savings);
if (krwAmount != null) {
total += krwAmount;
}
} else {
// KRW인 경우 그대로 합산
total += savings; total += savings;
} else if (subscription.currency == 'USD') {
final converted = await _exchangeRateService.convertUsdToTarget(savings, defaultCurrency);
if (converted != null) {
total += converted;
}
} else if (defaultCurrency == 'USD') {
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
final converted = await _exchangeRateService.convertTargetToUsd(savings, subscription.currency);
if (converted != null) {
total += converted;
}
} }
} }
} }
@@ -100,60 +208,37 @@ class CurrencyUtil {
return total; return total;
} }
/// 이벤트 절약액을 표시 형식에 맞게 변환 /// 이벤트로 인한 총 절약액 계산 (원화로 환산) - 기존 호환성 유지
static Future<String> formatEventSavings( static Future<double> calculateTotalEventSavings(
SubscriptionModel subscription) async { List<SubscriptionModel> subscriptions) async {
return calculateTotalEventSavingsInDefaultCurrency(subscriptions, 'ko');
}
/// 이벤트 절약액을 표시 형식에 맞게 변환 (언어별)
static Future<String> formatEventSavingsWithLocale(
SubscriptionModel subscription, String locale) async {
if (!subscription.isCurrentlyInEvent) { if (!subscription.isCurrentlyInEvent) {
return ''; return '';
} }
final savings = subscription.eventSavings; final savings = subscription.eventSavings;
return formatAmountWithLocale(savings, subscription.currency, locale);
if (subscription.currency == 'USD') {
// USD 표시 + 원화 환산 금액
final usdFormatted = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(savings);
// 원화 환산 금액
final krwAmount = await _exchangeRateService
.getFormattedKrwAmount(savings);
return '$usdFormatted $krwAmount';
} else {
// 원화 표시
return NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(savings);
}
} }
/// 금액과 통화를 받아 포맷팅하여 반환 /// 이벤트 절약액을 표시 형식에 맞게 변환 - 기존 호환성 유지
static Future<String> formatEventSavings(
SubscriptionModel subscription) async {
return formatEventSavingsWithLocale(subscription, 'ko');
}
/// 금액과 통화를 받아 포맷팅하여 반환 (언어별)
static Future<String> formatAmountWithCurrencyAndLocale(
double amount, String currency, String locale) async {
return formatAmountWithLocale(amount, currency, locale);
}
/// 금액과 통화를 받아 포맷팅하여 반환 - 기존 호환성 유지
static Future<String> formatAmount(double amount, String currency) async { static Future<String> formatAmount(double amount, String currency) async {
if (currency == 'USD') { return formatAmountWithCurrencyAndLocale(amount, currency, 'ko');
// USD 표시 + 원화 환산 금액
final usdFormatted = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(amount);
// 원화 환산 금액
final krwAmount = await _exchangeRateService
.getFormattedKrwAmount(amount);
return '$usdFormatted $krwAmount';
} else {
// 원화 표시
return NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(amount);
}
} }
} }

View File

@@ -17,6 +17,8 @@ class ExchangeRateService {
// 캐싱된 환율 정보 // 캐싱된 환율 정보
double? _usdToKrwRate; double? _usdToKrwRate;
double? _usdToJpyRate;
double? _usdToCnyRate;
DateTime? _lastUpdated; DateTime? _lastUpdated;
// API 요청 URL (ExchangeRate-API 사용) // API 요청 URL (ExchangeRate-API 사용)
@@ -24,18 +26,20 @@ class ExchangeRateService {
// 기본 환율 상수 // 기본 환율 상수
static const double DEFAULT_USD_TO_KRW_RATE = 1350.0; static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
// 캐싱된 환율 반환 (동기적) // 캐싱된 환율 반환 (동기적)
double? get cachedUsdToKrwRate => _usdToKrwRate; double? get cachedUsdToKrwRate => _usdToKrwRate;
/// 현재 USD to KRW 환율 정보를 가져옵니다. /// 모든 환율 정보를 한 번에 가져옵니다.
/// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 반환합니다. /// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 사용합니다.
Future<double?> getUsdToKrwRate() async { Future<void> _fetchAllRatesIfNeeded() async {
// 캐싱된 데이터 있고 6시간 이내면 캐싱된 데이터 반환 // 캐싱된 데이터 있고 6시간 이내면 스킵
if (_usdToKrwRate != null && _lastUpdated != null) { if (_lastUpdated != null) {
final difference = DateTime.now().difference(_lastUpdated!); final difference = DateTime.now().difference(_lastUpdated!);
if (difference.inHours < 6) { if (difference.inHours < 6) {
return _usdToKrwRate; return;
} }
} }
@@ -45,19 +49,22 @@ class ExchangeRateService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
_usdToKrwRate = data['rates']['KRW'].toDouble(); _usdToKrwRate = data['rates']['KRW']?.toDouble();
_usdToJpyRate = data['rates']['JPY']?.toDouble();
_usdToCnyRate = data['rates']['CNY']?.toDouble();
_lastUpdated = DateTime.now(); _lastUpdated = DateTime.now();
return _usdToKrwRate;
} else {
// 실패 시 캐싱된 값이라도 반환
return _usdToKrwRate;
} }
} catch (e) { } catch (e) {
// 오류 발생 시 캐싱된 값이라도 반환 // 오류 발생 시 기본값 사용
return _usdToKrwRate;
} }
} }
/// 현재 USD to KRW 환율 정보를 가져옵니다.
Future<double?> getUsdToKrwRate() async {
await _fetchAllRatesIfNeeded();
return _usdToKrwRate;
}
/// USD 금액을 KRW로 변환합니다. /// USD 금액을 KRW로 변환합니다.
Future<double?> convertUsdToKrw(double usdAmount) async { Future<double?> convertUsdToKrw(double usdAmount) async {
final rate = await getUsdToKrwRate(); final rate = await getUsdToKrwRate();
@@ -67,6 +74,48 @@ class ExchangeRateService {
return null; return null;
} }
/// USD 금액을 지정된 통화로 변환합니다.
Future<double?> convertUsdToTarget(double usdAmount, String targetCurrency) async {
await _fetchAllRatesIfNeeded();
switch (targetCurrency) {
case 'KRW':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
return usdAmount * rate;
case 'JPY':
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
return usdAmount * rate;
case 'CNY':
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
return usdAmount * rate;
case 'USD':
return usdAmount;
default:
return null;
}
}
/// 지정된 통화를 USD로 변환합니다.
Future<double?> convertTargetToUsd(double amount, String sourceCurrency) async {
await _fetchAllRatesIfNeeded();
switch (sourceCurrency) {
case 'KRW':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
return amount / rate;
case 'JPY':
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
return amount / rate;
case 'CNY':
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
return amount / rate;
case 'USD':
return amount;
default:
return null;
}
}
/// 현재 환율 정보를 포맷팅하여 텍스트로 반환합니다. /// 현재 환율 정보를 포맷팅하여 텍스트로 반환합니다.
Future<String> getFormattedExchangeRateInfo() async { Future<String> getFormattedExchangeRateInfo() async {
final rate = await getUsdToKrwRate(); final rate = await getUsdToKrwRate();
@@ -76,11 +125,42 @@ class ExchangeRateService {
symbol: '', symbol: '',
decimalDigits: 0, decimalDigits: 0,
).format(rate); ).format(rate);
return '오늘 기준 환율 : $formattedRate'; return formattedRate;
} }
return ''; return '';
} }
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
await _fetchAllRatesIfNeeded();
switch (locale) {
case 'ko':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
return NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(rate);
case 'ja':
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
return NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
).format(rate);
case 'zh':
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
return NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
).format(rate);
default:
return '';
}
}
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다. /// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
Future<String> getFormattedKrwAmount(double usdAmount) async { Future<String> getFormattedKrwAmount(double usdAmount) async {
final krwAmount = await convertUsdToKrw(usdAmount); final krwAmount = await convertUsdToKrw(usdAmount);
@@ -94,4 +174,46 @@ class ExchangeRateService {
} }
return ''; return '';
} }
/// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다.
Future<String> getFormattedAmountForLocale(double usdAmount, String locale) async {
String targetCurrency;
String localeCode;
String symbol;
int decimalDigits;
switch (locale) {
case 'ko':
targetCurrency = 'KRW';
localeCode = 'ko_KR';
symbol = '';
decimalDigits = 0;
break;
case 'ja':
targetCurrency = 'JPY';
localeCode = 'ja_JP';
symbol = '¥';
decimalDigits = 0;
break;
case 'zh':
targetCurrency = 'CNY';
localeCode = 'zh_CN';
symbol = '¥';
decimalDigits = 2;
break;
default:
return '\$$usdAmount';
}
final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency);
if (convertedAmount != null) {
final formattedAmount = NumberFormat.currency(
locale: localeCode,
symbol: symbol,
decimalDigits: decimalDigits,
).format(convertedAmount);
return '($formattedAmount)';
}
return '';
}
} }

View File

@@ -76,7 +76,7 @@ class SmsScanner {
try { try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
final billingCycle = sms['billingCycle'] as String? ?? '월간'; final billingCycle = SubscriptionModel.normalizeBillingCycle(sms['billingCycle'] as String? ?? 'monthly');
final nextBillingDateStr = sms['nextBillingDate'] as String?; final nextBillingDateStr = sms['nextBillingDate'] as String?;
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
@@ -142,7 +142,7 @@ class SmsScanner {
} }
// 결제 주기별 다음 결제일 계산 // 결제 주기별 다음 결제일 계산
if (billingCycle == '월간') { if (billingCycle == 'monthly') {
int month = now.month; int month = now.month;
int year = now.year; int year = now.year;
@@ -156,7 +156,7 @@ class SmsScanner {
} }
return DateTime(year, month, billingDate.day); return DateTime(year, month, billingDate.day);
} else if (billingCycle == '연간') { } else if (billingCycle == 'yearly') {
// 올해의 결제일이 지났는지 확인 // 올해의 결제일이 지났는지 확인
final thisYearBilling = final thisYearBilling =
DateTime(now.year, billingDate.month, billingDate.day); DateTime(now.year, billingDate.month, billingDate.day);
@@ -165,7 +165,7 @@ class SmsScanner {
} else { } else {
return thisYearBilling; return thisYearBilling;
} }
} else if (billingCycle == '주간') { } else if (billingCycle == 'weekly') {
// 가장 가까운 다음 주 같은 요일 계산 // 가장 가까운 다음 주 같은 요일 계산
final dayDifference = billingDate.weekday - now.weekday; final dayDifference = billingDate.weekday - now.weekday;
final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference; final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference;

View File

@@ -747,6 +747,60 @@ class SubscriptionUrlMatcher {
return _getCategoryForLegacyService(serviceName); return _getCategoryForLegacyService(serviceName);
} }
/// 현재 로케일에 따라 서비스 표시명 가져오기
static Future<String> getServiceDisplayName({
required String serviceName,
required String locale,
}) async {
await initialize();
if (_servicesData == null) {
return serviceName;
}
final lowerName = serviceName.toLowerCase().trim();
final categories = _servicesData!['categories'] as Map<String, dynamic>;
// JSON에서 서비스 찾기
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final data = serviceData as Map<String, dynamic>;
final names = List<String>.from(data['names'] ?? []);
// names 배열에 있는지 확인
for (final name in names) {
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
// 로케일에 따라 적절한 이름 반환
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
// nameKr/nameEn에 직접 매칭 확인
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
if (lowerName == nameKr || lowerName == nameEn) {
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
}
// 찾지 못한 경우 원래 이름 반환
return serviceName;
}
/// 카테고리 키를 실제 카테고리 ID로 매핑 /// 카테고리 키를 실제 카테고리 ID로 매핑
static String _getCategoryIdByKey(String key) { static String _getCategoryIdByKey(String key) {
// 여기에 실제 앱의 카테고리 ID 매핑을 추가 // 여기에 실제 앱의 카테고리 ID 매핑을 추가

View File

@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
@@ -8,11 +9,13 @@ class SubscriptionCategoryHelper {
/// ///
/// [subscriptions] 구독 목록 /// [subscriptions] 구독 목록
/// [categoryProvider] 카테고리 제공자 /// [categoryProvider] 카테고리 제공자
/// [context] BuildContext for localization
/// ///
/// 반환값: 카테고리 이름을 키로 하고 해당 카테고리에 속하는 구독 목록을 값으로 가지는 Map /// 반환값: 카테고리 이름을 키로 하고 해당 카테고리에 속하는 구독 목록을 값으로 가지는 Map
static Map<String, List<SubscriptionModel>> categorizeSubscriptions( static Map<String, List<SubscriptionModel>> categorizeSubscriptions(
List<SubscriptionModel> subscriptions, List<SubscriptionModel> subscriptions,
CategoryProvider categoryProvider, CategoryProvider categoryProvider,
BuildContext context,
) { ) {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions = {}; final Map<String, List<SubscriptionModel>> categorizedSubscriptions = {};
@@ -36,89 +39,89 @@ class SubscriptionCategoryHelper {
// 음악 // 음악
if (_isInCategory( if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.musicServices)) { subscription.serviceName, SubscriptionUrlMatcher.musicServices)) {
if (!categorizedSubscriptions.containsKey('음악')) { if (!categorizedSubscriptions.containsKey('music')) {
categorizedSubscriptions['음악'] = []; categorizedSubscriptions['music'] = [];
} }
categorizedSubscriptions['음악']!.add(subscription); categorizedSubscriptions['music']!.add(subscription);
} }
// OTT(동영상) // OTT(동영상)
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.ottServices)) { subscription.serviceName, SubscriptionUrlMatcher.ottServices)) {
if (!categorizedSubscriptions.containsKey('OTT(동영상)')) { if (!categorizedSubscriptions.containsKey('ottVideo')) {
categorizedSubscriptions['OTT(동영상)'] = []; categorizedSubscriptions['ottVideo'] = [];
} }
categorizedSubscriptions['OTT(동영상)']!.add(subscription); categorizedSubscriptions['ottVideo']!.add(subscription);
} }
// 저장/클라우드 // 저장/클라우드
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.storageServices)) { subscription.serviceName, SubscriptionUrlMatcher.storageServices)) {
if (!categorizedSubscriptions.containsKey('저장/클라우드')) { if (!categorizedSubscriptions.containsKey('storageCloud')) {
categorizedSubscriptions['저장/클라우드'] = []; categorizedSubscriptions['storageCloud'] = [];
} }
categorizedSubscriptions['저장/클라우드']!.add(subscription); categorizedSubscriptions['storageCloud']!.add(subscription);
} }
// 통신 · 인터넷 · TV // 통신 · 인터넷 · TV
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.telecomServices)) { subscription.serviceName, SubscriptionUrlMatcher.telecomServices)) {
if (!categorizedSubscriptions.containsKey('통신 · 인터넷 · TV')) { if (!categorizedSubscriptions.containsKey('telecomInternetTv')) {
categorizedSubscriptions['통신 · 인터넷 · TV'] = []; categorizedSubscriptions['telecomInternetTv'] = [];
} }
categorizedSubscriptions['통신 · 인터넷 · TV']!.add(subscription); categorizedSubscriptions['telecomInternetTv']!.add(subscription);
} }
// 생활/라이프스타일 // 생활/라이프스타일
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.lifestyleServices)) { subscription.serviceName, SubscriptionUrlMatcher.lifestyleServices)) {
if (!categorizedSubscriptions.containsKey('생활/라이프스타일')) { if (!categorizedSubscriptions.containsKey('lifestyle')) {
categorizedSubscriptions['생활/라이프스타일'] = []; categorizedSubscriptions['lifestyle'] = [];
} }
categorizedSubscriptions['생활/라이프스타일']!.add(subscription); categorizedSubscriptions['lifestyle']!.add(subscription);
} }
// 쇼핑/이커머스 // 쇼핑/이커머스
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.shoppingServices)) { subscription.serviceName, SubscriptionUrlMatcher.shoppingServices)) {
if (!categorizedSubscriptions.containsKey('쇼핑/이커머스')) { if (!categorizedSubscriptions.containsKey('shoppingEcommerce')) {
categorizedSubscriptions['쇼핑/이커머스'] = []; categorizedSubscriptions['shoppingEcommerce'] = [];
} }
categorizedSubscriptions['쇼핑/이커머스']!.add(subscription); categorizedSubscriptions['shoppingEcommerce']!.add(subscription);
} }
// 프로그래밍 // 프로그래밍
else if (_isInCategory(subscription.serviceName, else if (_isInCategory(subscription.serviceName,
SubscriptionUrlMatcher.programmingServices)) { SubscriptionUrlMatcher.programmingServices)) {
if (!categorizedSubscriptions.containsKey('프로그래밍')) { if (!categorizedSubscriptions.containsKey('programming')) {
categorizedSubscriptions['프로그래밍'] = []; categorizedSubscriptions['programming'] = [];
} }
categorizedSubscriptions['프로그래밍']!.add(subscription); categorizedSubscriptions['programming']!.add(subscription);
} }
// 협업/오피스 // 협업/오피스
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.officeTools)) { subscription.serviceName, SubscriptionUrlMatcher.officeTools)) {
if (!categorizedSubscriptions.containsKey('협업/오피스')) { if (!categorizedSubscriptions.containsKey('collaborationOffice')) {
categorizedSubscriptions['협업/오피스'] = []; categorizedSubscriptions['collaborationOffice'] = [];
} }
categorizedSubscriptions['협업/오피스']!.add(subscription); categorizedSubscriptions['collaborationOffice']!.add(subscription);
} }
// AI 서비스 // AI 서비스
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.aiServices)) { subscription.serviceName, SubscriptionUrlMatcher.aiServices)) {
if (!categorizedSubscriptions.containsKey('AI 서비스')) { if (!categorizedSubscriptions.containsKey('aiService')) {
categorizedSubscriptions['AI 서비스'] = []; categorizedSubscriptions['aiService'] = [];
} }
categorizedSubscriptions['AI 서비스']!.add(subscription); categorizedSubscriptions['aiService']!.add(subscription);
} }
// 기타 // 기타
else if (_isInCategory( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.otherServices)) { subscription.serviceName, SubscriptionUrlMatcher.otherServices)) {
if (!categorizedSubscriptions.containsKey('기타')) { if (!categorizedSubscriptions.containsKey('other')) {
categorizedSubscriptions['기타'] = []; categorizedSubscriptions['other'] = [];
} }
categorizedSubscriptions['기타']!.add(subscription); categorizedSubscriptions['other']!.add(subscription);
} }
// 미분류된 서비스 // 미분류된 서비스
else { else {
if (!categorizedSubscriptions.containsKey('미분류')) { if (!categorizedSubscriptions.containsKey('uncategorized')) {
categorizedSubscriptions['미분류'] = []; categorizedSubscriptions['uncategorized'] = [];
} }
categorizedSubscriptions['미분류']!.add(subscription); categorizedSubscriptions['uncategorized']!.add(subscription);
} }
} }

View File

@@ -3,6 +3,7 @@ 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';
/// 구독 추가 화면의 App Bar /// 구독 추가 화면의 App Bar
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
@@ -49,7 +50,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: Text( title: Text(
'구독 추가', AppLocalizations.of(context).addSubscription,
style: TextStyle( style: TextStyle(
fontFamily: 'Montserrat', fontFamily: 'Montserrat',
fontSize: 24, fontSize: 24,
@@ -93,7 +94,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
color: Color(0xFF3B82F6), color: Color(0xFF3B82F6),
), ),
onPressed: onScanSMS, onPressed: onScanSMS,
tooltip: 'SMS에서 구독 정보 스캔', tooltip: AppLocalizations.of(context).scanTextMessages,
), ),
], ],
), ),

View File

@@ -3,6 +3,7 @@ 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';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 이벤트/할인 섹션 /// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget { class AddSubscriptionEventSection extends StatelessWidget {
@@ -75,13 +76,32 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Builder(
'이벤트 가격', builder: (context) {
style: TextStyle( final locale = Localizations.localeOf(context);
String titleText;
switch (locale.languageCode) {
case 'ko':
titleText = '이벤트 가격';
break;
case 'ja':
titleText = 'イベント価格';
break;
case 'zh':
titleText = '活动价格';
break;
default:
titleText = 'Event Price';
}
return Text(
titleText,
style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),
);
},
), ),
], ],
), ),
@@ -133,13 +153,32 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Builder(
'할인 또는 프로모션 가격을 설정하세요', builder: (context) {
final locale = Localizations.localeOf(context);
String infoText;
switch (locale.languageCode) {
case 'ko':
infoText = '할인 또는 프로모션 가격을 설정하세요';
break;
case 'ja':
infoText = '割引またはプロモーション価格を設定してください';
break;
case 'zh':
infoText = '设置折扣或促销价格';
break;
default:
infoText = 'Set up discount or promotion price';
}
return Text(
infoText,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
);
},
), ),
), ),
], ],
@@ -148,7 +187,29 @@ class AddSubscriptionEventSection extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
// 이벤트 기간 // 이벤트 기간
DateRangePickerField( Builder(
builder: (context) {
final locale = Localizations.localeOf(context);
String startLabel;
String endLabel;
switch (locale.languageCode) {
case 'ko':
startLabel = '시작일';
endLabel = '종료일';
break;
case 'ja':
startLabel = '開始日';
endLabel = '終了日';
break;
case 'zh':
startLabel = '开始日期';
endLabel = '结束日期';
break;
default:
startLabel = 'Start Date';
endLabel = 'End Date';
}
return DateRangePickerField(
startDate: controller.eventStartDate, startDate: controller.eventStartDate,
endDate: controller.eventEndDate, endDate: controller.eventEndDate,
onStartDateSelected: (date) { onStartDateSelected: (date) {
@@ -165,18 +226,49 @@ class AddSubscriptionEventSection extends StatelessWidget {
controller.eventEndDate = date; controller.eventEndDate = date;
}); });
}, },
startLabel: '시작일', startLabel: startLabel,
endLabel: '종료일', endLabel: endLabel,
primaryColor: controller.gradientColors[0], primaryColor: controller.gradientColors[0],
);
},
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 이벤트 가격 // 이벤트 가격
CurrencyInputField( Builder(
builder: (BuildContext innerContext) {
// 현재 로케일 확인
final currentLocale = Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정
String eventPriceLabel;
String eventPriceHint;
switch (currentLocale.languageCode) {
case 'ko':
eventPriceLabel = '이벤트 가격';
eventPriceHint = '할인된 가격을 입력하세요';
break;
case 'ja':
eventPriceLabel = 'イベント価格';
eventPriceHint = '割引価格を入力してください';
break;
case 'zh':
eventPriceLabel = '活动价格';
eventPriceHint = '输入折扣价格';
break;
default:
eventPriceLabel = 'Event Price';
eventPriceHint = 'Enter discounted price';
}
return CurrencyInputField(
controller: controller.eventPriceController, controller: controller.eventPriceController,
currency: controller.currency, currency: controller.currency,
label: '이벤트 가격', label: eventPriceLabel,
hintText: '할인된 가격을 입력하세요', hintText: eventPriceHint,
);
},
), ),
], ],
), ),

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../../controllers/add_subscription_controller.dart'; import '../../controllers/add_subscription_controller.dart';
import '../../providers/category_provider.dart'; import '../../providers/category_provider.dart';
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';
@@ -67,9 +68,9 @@ class AddSubscriptionForm extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Text(
'서비스 정보', AppLocalizations.of(context).serviceInfo,
style: TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
@@ -84,15 +85,15 @@ class AddSubscriptionForm extends StatelessWidget {
BaseTextField( BaseTextField(
controller: controller.serviceNameController, controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus, focusNode: controller.serviceNameFocus,
label: '서비스명', label: AppLocalizations.of(context).labelServiceName,
hintText: '예: Netflix, Spotify', hintText: AppLocalizations.of(context).hintServiceName,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onEditingComplete: () { onEditingComplete: () {
controller.monthlyCostFocus.requestFocus(); controller.monthlyCostFocus.requestFocus();
}, },
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '서비스명을 입력해주세요'; return AppLocalizations.of(context).serviceNameRequired;
} }
return null; return null;
}, },
@@ -108,7 +109,7 @@ class AddSubscriptionForm extends StatelessWidget {
child: CurrencyInputField( child: CurrencyInputField(
controller: controller.monthlyCostController, controller: controller.monthlyCostController,
currency: controller.currency, currency: controller.currency,
label: '월 지출', label: AppLocalizations.of(context).labelMonthlyExpense,
focusNode: controller.monthlyCostFocus, focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onEditingComplete: () { onEditingComplete: () {
@@ -116,7 +117,7 @@ class AddSubscriptionForm extends StatelessWidget {
}, },
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '금액을 입력해주세요'; return AppLocalizations.of(context).amountRequired;
} }
return null; return null;
}, },
@@ -127,9 +128,9 @@ class AddSubscriptionForm extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'통화', AppLocalizations.of(context).currency,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -155,9 +156,10 @@ class AddSubscriptionForm extends StatelessWidget {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'결제 주기', AppLocalizations.of(context).billingCycle,
style: TextStyle( style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -185,7 +187,7 @@ class AddSubscriptionForm extends StatelessWidget {
controller.nextBillingDate = date; controller.nextBillingDate = date;
}); });
}, },
label: '다음 결제일', label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(), firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)), lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: controller.gradientColors[0], primaryColor: controller.gradientColors[0],
@@ -196,8 +198,8 @@ class AddSubscriptionForm extends StatelessWidget {
BaseTextField( BaseTextField(
controller: controller.websiteUrlController, controller: controller.websiteUrlController,
focusNode: controller.websiteUrlFocus, focusNode: controller.websiteUrlFocus,
label: '웹사이트 URL (선택)', label: AppLocalizations.of(context).websiteUrlOptional,
hintText: 'https://example.com', hintText: AppLocalizations.of(context).hintWebsiteUrl,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: Icon( prefixIcon: Icon(
Icons.link_rounded, Icons.link_rounded,
@@ -212,9 +214,9 @@ class AddSubscriptionForm extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'카테고리', AppLocalizations.of(context).category,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -243,4 +245,3 @@ class AddSubscriptionForm extends StatelessWidget {
); );
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart'; import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 헤더 섹션 /// 구독 추가 화면의 헤더 섹션
class AddSubscriptionHeader extends StatelessWidget { class AddSubscriptionHeader extends StatelessWidget {
@@ -54,23 +55,23 @@ class AddSubscriptionHeader extends StatelessWidget {
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
const Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'새 구독 추가', AppLocalizations.of(context).newSubscriptionAdd,
style: TextStyle( style: const TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white, color: Colors.white,
letterSpacing: -0.5, letterSpacing: -0.5,
), ),
), ),
SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'서비스 정보를 입력해주세요', AppLocalizations.of(context).enterServiceInfo,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.white70, color: Colors.white70,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart'; import '../../controllers/add_subscription_controller.dart';
import '../common/buttons/primary_button.dart'; import '../common/buttons/primary_button.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 저장 버튼 /// 구독 추가 화면의 저장 버튼
class AddSubscriptionSaveButton extends StatelessWidget { class AddSubscriptionSaveButton extends StatelessWidget {
@@ -37,7 +38,7 @@ class AddSubscriptionSaveButton extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 80), padding: const EdgeInsets.only(bottom: 80),
child: PrimaryButton( child: PrimaryButton(
text: '구독 추가하기', text: AppLocalizations.of(context).addSubscriptionButton,
icon: Icons.add_circle_outline, icon: Icons.add_circle_outline,
onPressed: controller.isLoading onPressed: controller.isLoading
? null ? null

View File

@@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
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 '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget { class AnalysisBadge extends StatelessWidget {
@@ -54,17 +57,26 @@ class AnalysisBadge extends StatelessWidget {
), ),
const SizedBox(height: 0), const SizedBox(height: 0),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.formatAmount( future: CurrencyUtil.formatAmountWithLocale(
subscription.monthlyCost, subscription.monthlyCost,
subscription.currency, subscription.currency,
context.read<LocaleProvider>().locale.languageCode,
), ),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
final amountText = snapshot.data!; final amountText = snapshot.data!;
// 금액이 너무 길면 축약 // 금액이 너무 길면 축약 (괄호 제거)
final displayText = amountText.length > 8 String displayText = amountText;
? amountText.replaceAll('', '').trim() if (amountText.length > 12) {
: amountText; // 괄호 안의 내용 제거
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
}
if (displayText.length > 10) {
// 통화 기호만 남기고 숫자만 표시
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText = displayText.replaceAll(currencySymbol, '').trim();
displayText = '$currencySymbol${displayText.substring(0, 6)}...';
}
return Text( return Text(
displayText, displayText,
style: const TextStyle( style: const TextStyle(

View File

@@ -6,6 +6,7 @@ import '../../services/currency_util.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯 /// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget { class EventAnalysisCard extends StatelessWidget {
@@ -50,7 +51,7 @@ class EventAnalysisCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: '이벤트 할인 현황', text: AppLocalizations.of(context).eventDiscountStatus,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
@@ -78,7 +79,7 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${provider.activeEventSubscriptions.length}개 진행중', AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -119,9 +120,9 @@ class EventAnalysisCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const ThemedText( ThemedText(
'월간 절약 금액', AppLocalizations.of(context).monthlySavingAmount,
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -144,9 +145,9 @@ class EventAnalysisCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const ThemedText( ThemedText(
'진행중인 이벤트', AppLocalizations.of(context).eventsInProgress,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -246,7 +247,7 @@ class EventAnalysisCard extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
'$discountRate% 할인', '$discountRate${AppLocalizations.of(context).discountPercent}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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 '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
/// 월별 지출 현황을 차트로 보여주는 카드 위젯 /// 월별 지출 현황을 차트로 보여주는 카드 위젯
class MonthlyExpenseChartCard extends StatelessWidget { class MonthlyExpenseChartCard extends StatelessWidget {
@@ -17,12 +20,64 @@ class MonthlyExpenseChartCard extends StatelessWidget {
required this.animationController, required this.animationController,
}); });
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
double _calculateChartMaxY(double maxValue, String locale) {
final currency = CurrencyUtil.getDefaultCurrency(locale);
if (currency == 'KRW' || currency == 'JPY') {
// 소수점이 없는 통화 (원화, 엔화)
if (maxValue <= 0) return 100000;
if (maxValue <= 10000) return 10000;
if (maxValue <= 50000) return 50000;
if (maxValue <= 100000) return 100000;
if (maxValue <= 200000) return 200000;
if (maxValue <= 500000) return 500000;
if (maxValue <= 1000000) return 1000000;
// 큰 금액은 자릿수에 맞춰 반올림
final magnitude = math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
} else {
// 소수점이 있는 통화 (달러, 위안)
if (maxValue <= 0) return 100.0;
if (maxValue <= 10) return 10.0;
if (maxValue <= 25) return 25.0;
if (maxValue <= 50) return 50.0;
if (maxValue <= 100) return 100.0;
if (maxValue <= 250) return 250.0;
if (maxValue <= 500) return 500.0;
if (maxValue <= 1000) return 1000.0;
// 큰 금액은 100 단위로 반올림
return ((maxValue / 100).ceil() * 100).toDouble();
}
}
/// 그리드 라인 간격을 계산합니다
double _calculateGridInterval(double maxY, String currency) {
if (currency == 'KRW' || currency == 'JPY') {
// 4등분하되 깔끔한 숫자로
if (maxY <= 40000) return 10000;
if (maxY <= 100000) return 25000;
if (maxY <= 200000) return 50000;
if (maxY <= 400000) return 100000;
return maxY / 4;
} else {
// 달러 등은 4등분
if (maxY <= 40) return 10;
if (maxY <= 100) return 25;
if (maxY <= 200) return 50;
if (maxY <= 400) return 100;
return maxY / 4;
}
}
// 월간 지출 차트 데이터 // 월간 지출 차트 데이터
List<BarChartGroupData> _getMonthlyBarGroups() { List<BarChartGroupData> _getMonthlyBarGroups(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 = calculatedMax > 0 ? calculatedMax : 100000.0; // 기본값 10만원 final maxAmount = _calculateChartMaxY(calculatedMax, locale);
for (int i = 0; i < monthlyData.length; i++) { for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i]; final data = monthlyData[i];
@@ -44,7 +99,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData( backDrawRodData: BackgroundBarChartRodData(
show: true, show: true,
toY: maxAmount + (maxAmount * 0.1), toY: maxAmount,
color: AppColors.navyGray.withValues(alpha: 0.1), color: AppColors.navyGray.withValues(alpha: 0.1),
), ),
), ),
@@ -58,6 +113,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final locale = context.watch<LocaleProvider>().locale.languageCode;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -84,14 +140,14 @@ class MonthlyExpenseChartCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: '월별 지출 현황', text: AppLocalizations.of(context).monthlyExpenseTitle,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ThemedText.subtitle( ThemedText.subtitle(
text: '최근 6개월간 추이', text: AppLocalizations.of(context).recentSixMonthsTrend,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
), ),
@@ -103,25 +159,26 @@ class MonthlyExpenseChartCard extends StatelessWidget {
child: BarChart( child: BarChart(
BarChartData( BarChartData(
alignment: BarChartAlignment.spaceAround, alignment: BarChartAlignment.spaceAround,
maxY: math.max( maxY: _calculateChartMaxY(
monthlyData.fold<double>( monthlyData.fold<double>(
0, 0,
(max, data) => math.max( (max, data) => math.max(
max, data['totalExpense'] as double)) * max, data['totalExpense'] as double)),
1.2, locale
100000.0 // 최소값 10만원
), ),
barGroups: _getMonthlyBarGroups(), barGroups: _getMonthlyBarGroups(locale),
gridData: FlGridData( gridData: FlGridData(
show: true, show: true,
drawVerticalLine: false, drawVerticalLine: false,
horizontalInterval: math.max( horizontalInterval: _calculateGridInterval(
_calculateChartMaxY(
monthlyData.fold<double>( monthlyData.fold<double>(
0, 0,
(max, data) => math.max(max, (max, data) => math.max(max,
data['totalExpense'] as double)) / data['totalExpense'] as double)),
4, locale
25000.0 // 최소 간격 2.5만원 ),
CurrencyUtil.getDefaultCurrency(locale)
), ),
getDrawingHorizontalLine: (value) { getDrawingHorizontalLine: (value) {
return FlLine( return FlLine(
@@ -176,9 +233,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
children: [ children: [
TextSpan( TextSpan(
text: CurrencyUtil.formatTotalAmount( text: CurrencyUtil.formatTotalAmountWithLocale(
monthlyData[group.x]['totalExpense'] monthlyData[group.x]['totalExpense']
as double), as double,
locale),
style: const TextStyle( style: const TextStyle(
color: Color(0xFFFBBF24), color: Color(0xFFFBBF24),
fontSize: 14, fontSize: 14,
@@ -196,7 +254,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
child: ThemedText.caption( child: ThemedText.caption(
text: '월 구독 지출', text: AppLocalizations.of(context).monthlySubscriptionExpense,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -1,72 +1,175 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
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 '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import 'analysis_badge.dart'; import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/locale_provider.dart';
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯 /// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
class SubscriptionPieChartCard extends StatelessWidget { class SubscriptionPieChartCard extends StatefulWidget {
final List<SubscriptionModel> subscriptions; final List<SubscriptionModel> subscriptions;
final AnimationController animationController; final AnimationController animationController;
final int touchedIndex;
final Function(int) onPieTouch;
const SubscriptionPieChartCard({ const SubscriptionPieChartCard({
super.key, super.key,
required this.subscriptions, required this.subscriptions,
required this.animationController, required this.animationController,
required this.touchedIndex,
required this.onPieTouch,
}); });
// 파이 차트 섹션 데이터 @override
List<PieChartSectionData> _getPieSections() { State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState();
if (subscriptions.isEmpty) return []; }
final colors = [ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
const Color(0xFF3B82F6), int _touchedIndex = -1;
const Color(0xFF10B981), late Future<List<PieChartSectionData>> _pieSectionsFuture;
const Color(0xFFF59E0B), String? _lastLocale;
const Color(0xFFEF4444),
const Color(0xFF8B5CF6), static const _chartColors = [
const Color(0xFF0EA5E9), Color(0xFF3B82F6),
const Color(0xFFEC4899), Color(0xFF10B981),
Color(0xFFF59E0B),
Color(0xFFEF4444),
Color(0xFF8B5CF6),
Color(0xFF0EA5E9),
Color(0xFFEC4899),
]; ];
// 개별 구독의 비율 계산을 위한 값들 @override
void initState() {
super.initState();
_initializeFuture();
}
@override
void didUpdateWidget(SubscriptionPieChartCard oldWidget) {
super.didUpdateWidget(oldWidget);
// subscriptions나 locale이 변경된 경우만 Future 재생성
final currentLocale = context.read<LocaleProvider>().locale.languageCode;
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
_lastLocale != currentLocale) {
_initializeFuture();
}
}
void _initializeFuture() {
_lastLocale = context.read<LocaleProvider>().locale.languageCode;
_pieSectionsFuture = _getPieSections();
}
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i].id != b[i].id ||
a[i].currentPrice != b[i].currentPrice ||
a[i].currency != b[i].currency ||
a[i].serviceName != b[i].serviceName) {
return false;
}
}
return true;
}
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
Future<List<PieChartSectionData>> _getPieSections() async {
if (widget.subscriptions.isEmpty) return [];
// 현재 locale 가져오기
final locale = context.read<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
List<double> sectionValues = []; List<double> sectionValues = [];
// 각 구독의 원화 환산 금액 또는 원화 금액을 계 // 각 구독의 현재 가격을 언어별 기본 통화로 환
for (var subscription in subscriptions) { for (var subscription in widget.subscriptions) {
double value = subscription.monthlyCost; double value = subscription.currentPrice;
if (subscription.currency == 'USD') {
// USD의 경우 마지막으로 조회된 환율로 대략적인 계산 if (subscription.currency == defaultCurrency) {
// (정확한 계산은 비동기로 이루어지므로 UI 표시용으로만 사용) // 이미 기본 통화인 경우 그대로 사용
const rate = 1350.0; // 기본 환율 (실제 값은 API로 별도로 가져옴)
value = value * rate;
}
sectionValues.add(value); sectionValues.add(value);
} else if (subscription.currency == 'USD') {
// USD를 기본 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency);
sectionValues.add(converted ?? value);
} else if (defaultCurrency == 'USD') {
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency);
sectionValues.add(converted ?? value);
} else {
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
sectionValues.add(value);
}
} }
// 총합 계산 // 총합 계산
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
// 섹션 데이터 생성 // 총합이 0이면 빈 배열 반환
return List.generate(subscriptions.length, (i) { if (sectionsTotal == 0) return [];
final subscription = subscriptions[i];
// 섹션 데이터 생성 (터치 상태 제외)
final sections = List.generate(widget.subscriptions.length, (i) {
final percentage = (sectionValues[i] / sectionsTotal) * 100; final percentage = (sectionValues[i] / sectionsTotal) * 100;
final index = i % colors.length; final index = i % _chartColors.length;
final isTouched = touchedIndex == i;
final fontSize = isTouched ? 16.0 : 12.0;
final radius = isTouched ? 105.0 : 100.0;
return PieChartSectionData( return PieChartSectionData(
value: sectionValues[i], value: sectionValues[i],
title: '${percentage.toStringAsFixed(1)}%', title: '${percentage.toStringAsFixed(1)}%',
titleStyle: TextStyle( titleStyle: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
shadows: [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: _chartColors[index],
radius: 100.0,
titlePositionPercentageOffset: 0.6,
badgeWidget: null,
badgePositionPercentageOffset: .98,
);
});
return sections;
}
// 배지 위젯 생성
Widget _createBadgeWidget(int index) {
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
final subscription = widget.subscriptions[index];
final colorIndex = index % _chartColors.length;
return IgnorePointer(
child: AnalysisBadge(
size: 40,
borderColor: _chartColors[colorIndex],
subscription: subscription,
),
);
}
// 터치 상태를 반영한 섹션 데이터 생성
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) {
return List.generate(sections.length, (i) {
final section = sections[i];
final isTouched = _touchedIndex == i;
final fontSize = isTouched ? 16.0 : 12.0;
final radius = isTouched ? 105.0 : 100.0;
return PieChartSectionData(
value: section.value,
title: section.title,
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle(
fontSize: fontSize, fontSize: fontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -74,17 +177,11 @@ class SubscriptionPieChartCard extends StatelessWidget {
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
], ],
), ),
color: colors[index], color: section.color,
radius: radius, radius: radius,
titlePositionPercentageOffset: 0.6, titlePositionPercentageOffset: section.titlePositionPercentageOffset,
badgeWidget: isTouched badgeWidget: isTouched ? _createBadgeWidget(i) : null,
? AnalysisBadge( badgePositionPercentageOffset: section.badgePositionPercentageOffset,
size: 40,
borderColor: colors[index],
subscription: subscription,
)
: null,
badgePositionPercentageOffset: .98,
); );
}); });
} }
@@ -96,7 +193,7 @@ class SubscriptionPieChartCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: FadeTransition( child: FadeTransition(
opacity: CurvedAnimation( opacity: CurvedAnimation(
parent: 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: SlideTransition( child: SlideTransition(
@@ -104,7 +201,7 @@ class SubscriptionPieChartCard extends StatelessWidget {
begin: const Offset(0, 0.2), begin: const Offset(0, 0.2),
end: Offset.zero, end: Offset.zero,
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: 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: GlassmorphismCard(
@@ -120,13 +217,15 @@ class SubscriptionPieChartCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: '구독 서비스 비율', text: AppLocalizations.of(context).subscriptionServiceRatio,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfo(), future: CurrencyUtil.getExchangeRateInfoForLocale(
context.watch<LocaleProvider>().locale.languageCode
),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && if (snapshot.hasData &&
snapshot.data!.isNotEmpty) { snapshot.data!.isNotEmpty) {
@@ -145,7 +244,7 @@ class SubscriptionPieChartCard extends StatelessWidget {
), ),
), ),
child: Text( child: Text(
snapshot.data!, AppLocalizations.of(context).exchangeRateFormat(snapshot.data!),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -161,20 +260,20 @@ class SubscriptionPieChartCard extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ThemedText.subtitle( ThemedText.subtitle(
text: '월 지출 기준', text: AppLocalizations.of(context).monthlyExpenseBasis,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
child: subscriptions.isEmpty child: widget.subscriptions.isEmpty
? const SizedBox( ? SizedBox(
height: 250, height: 250,
child: Center( child: Center(
child: ThemedText( child: ThemedText(
'구독중인 서비스가 없습니다', AppLocalizations.of(context).noSubscriptionServices,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
), ),
), ),
@@ -182,52 +281,90 @@ class SubscriptionPieChartCard extends StatelessWidget {
) )
: SizedBox( : SizedBox(
height: 250, height: 250,
child: PieChart( child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Center(
child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices,
style: const TextStyle(
fontSize: 16,
),
),
);
}
return PieChart(
PieChartData( PieChartData(
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
sectionsSpace: 2, sectionsSpace: 2,
centerSpaceRadius: 60, centerSpaceRadius: 60,
sections: _getPieSections(), sections: _applyTouchedState(snapshot.data!),
pieTouchData: PieTouchData( pieTouchData: PieTouchData(
enabled: true,
touchCallback: (FlTouchEvent event, touchCallback: (FlTouchEvent event,
pieTouchResponse) { pieTouchResponse) {
if (!event // 터치 응답이 없거나 섹션이 없는 경우
.isInterestedForInteractions || if (pieTouchResponse == null ||
pieTouchResponse == null || pieTouchResponse.touchedSection == null) {
pieTouchResponse // 차트 밖으로 나갔을 때만 리셋
.touchedSection == if (_touchedIndex != -1) {
null) { setState(() {
onPieTouch(-1); _touchedIndex = -1;
});
}
return; return;
} }
onPieTouch(pieTouchResponse
final touchedIndex = pieTouchResponse
.touchedSection! .touchedSection!
.touchedSectionIndex); .touchedSectionIndex;
// 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) {
setState(() {
// 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
});
return;
}
// hover 이벤트 처리 (단순 표시)
if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex != touchedIndex) {
setState(() {
_touchedIndex = touchedIndex;
});
}
}
}, },
), ),
), ),
);
},
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 서비스 목록 // 서비스 목록
Column( Column(
children: subscriptions.isEmpty children: widget.subscriptions.isEmpty
? [] ? []
: List.generate( : List.generate(
subscriptions.length, widget.subscriptions.length,
(index) { (index) {
final subscription = final subscription =
subscriptions[index]; widget.subscriptions[index];
final color = [ final color = _chartColors[index % _chartColors.length];
const Color(0xFF3B82F6),
const Color(0xFF10B981),
const Color(0xFFF59E0B),
const Color(0xFFEF4444),
const Color(0xFF8B5CF6),
const Color(0xFF0EA5E9),
const Color(0xFFEC4899),
][index % 7];
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
bottom: 4.0), bottom: 4.0),
@@ -254,8 +391,9 @@ class SubscriptionPieChartCard extends StatelessWidget {
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil future: CurrencyUtil
.formatSubscriptionAmount( .formatSubscriptionAmountWithLocale(
subscription), subscription,
context.read<LocaleProvider>().locale.languageCode),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(

View File

@@ -1,12 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../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 '../../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 '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
import '../../l10n/app_localizations.dart';
/// 총 지출 요약을 보여주는 카드 위젯 /// 총 지출 요약을 보여주는 카드 위젯
class TotalExpenseSummaryCard extends StatelessWidget { class TotalExpenseSummaryCard extends StatelessWidget {
@@ -23,6 +26,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final locale = context.watch<LocaleProvider>().locale.languageCode;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -52,7 +56,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: '총 지출 요약', text: AppLocalizations.of(context).totalExpenseSummary,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
@@ -63,14 +67,14 @@ class TotalExpenseSummaryCard extends StatelessWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
onPressed: () async { onPressed: () async {
final totalExpenseText = CurrencyUtil.formatTotalAmount(totalExpense); final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale);
await Clipboard.setData( await Clipboard.setData(
ClipboardData(text: totalExpenseText)); ClipboardData(text: totalExpenseText));
HapticFeedbackHelper.lightImpact(); HapticFeedbackHelper.lightImpact();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('총 지출액이 복사되었습니다: $totalExpenseText'), content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -89,7 +93,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ThemedText.subtitle( ThemedText.subtitle(
text: '월 단위 총액', text: AppLocalizations.of(context).monthlyTotalAmount,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
), ),
@@ -103,7 +107,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ThemedText.caption( ThemedText.caption(
text: '총 지출', text: AppLocalizations.of(context).totalExpense,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -111,7 +115,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
CurrencyUtil.formatTotalAmount(totalExpense), CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale),
style: const TextStyle( style: const TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -148,7 +152,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
ThemedText.caption( ThemedText.caption(
text: '총 서비스', text: AppLocalizations.of(context).totalServices,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -156,7 +160,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
ThemedText( ThemedText(
'${subscriptions.length}', AppLocalizations.of(context).subscriptionCount(subscriptions.length),
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -190,7 +194,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
ThemedText.caption( ThemedText.caption(
text: '평균 요금', text: AppLocalizations.of(context).averageCost,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -198,10 +202,11 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
ThemedText( ThemedText(
CurrencyUtil.formatTotalAmount( CurrencyUtil.formatTotalAmountWithLocale(
subscriptions.isEmpty subscriptions.isEmpty
? 0 ? 0
: totalExpense / subscriptions.length), : totalExpense / subscriptions.length,
locale),
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -7,6 +7,7 @@ import '../models/subscription_model.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import 'animated_page_transitions.dart'; import 'animated_page_transitions.dart';
import '../l10n/app_localizations.dart';
/// 앱 전체의 네비게이션을 관리하는 클래스 /// 앱 전체의 네비게이션을 관리하는 클래스
class AppNavigator { class AppNavigator {
@@ -118,16 +119,16 @@ class AppNavigator {
final shouldExit = await showDialog<bool>( final shouldExit = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('앱 종료'), title: Text(AppLocalizations.of(context).exitApp),
content: const Text('SubManager를 종료하시겠습니까?'), content: Text(AppLocalizations.of(context).exitAppConfirm),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'), child: Text(AppLocalizations.of(context).cancel),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('종료'), child: Text(AppLocalizations.of(context).exit),
), ),
], ],
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../l10n/app_localizations.dart';
/// 카테고리별 구독 그룹의 헤더 위젯 /// 카테고리별 구독 그룹의 헤더 위젯
/// ///
@@ -10,6 +11,8 @@ class CategoryHeaderWidget extends StatelessWidget {
final int subscriptionCount; final int subscriptionCount;
final double totalCostUSD; final double totalCostUSD;
final double totalCostKRW; final double totalCostKRW;
final double totalCostJPY;
final double totalCostCNY;
const CategoryHeaderWidget({ const CategoryHeaderWidget({
Key? key, Key? key,
@@ -17,6 +20,8 @@ class CategoryHeaderWidget extends StatelessWidget {
required this.subscriptionCount, required this.subscriptionCount,
required this.totalCostUSD, required this.totalCostUSD,
required this.totalCostKRW, required this.totalCostKRW,
required this.totalCostJPY,
required this.totalCostCNY,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -38,7 +43,7 @@ class CategoryHeaderWidget extends StatelessWidget {
), ),
), ),
Text( Text(
_buildCostDisplay(), _buildCostDisplay(context),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -59,11 +64,11 @@ class CategoryHeaderWidget extends StatelessWidget {
} }
/// 통화별 합계를 표시하는 문자열을 생성합니다. /// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay() { String _buildCostDisplay(BuildContext context) {
final parts = <String>[]; final parts = <String>[];
// 개수는 항상 표시 // 개수는 항상 표시
parts.add('$subscriptionCount개'); parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리 // 통화 부분을 별도로 처리
final currencyParts = <String>[]; final currencyParts = <String>[];
@@ -88,6 +93,26 @@ class CategoryHeaderWidget extends StatelessWidget {
currencyParts.add(formatter.format(totalCostKRW)); currencyParts.add(formatter.format(totalCostKRW));
} }
// 엔화가 있는 경우
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
locale: 'ja_JP',
symbol: '¥',
decimalDigits: 0,
);
currencyParts.add(formatter.format(totalCostJPY));
}
// 위안화가 있는 경우
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
locale: 'zh_CN',
symbol: '¥',
decimalDigits: 2,
);
currencyParts.add(formatter.format(totalCostCNY));
}
// 통화가 하나 이상 있는 경우 // 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) { if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로 // 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로

View File

@@ -1,5 +1,6 @@
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';
/// 결제 주기 선택 위젯 /// 결제 주기 선택 위젯
/// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다. /// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다.
@@ -21,10 +22,21 @@ class BillingCycleSelector extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localization = AppLocalizations.of(context);
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism final cycles = isGlassmorphism
? ['매월', '분기별', '반기별', '매년'] ? [
: ['월간', '분기별', '반기별', '연간']; localization.billingCycleMonthly,
localization.billingCycleQuarterly,
localization.billingCycleHalfYearly,
localization.billingCycleYearly,
]
: [
localization.monthly,
localization.billingCycleQuarterly,
localization.billingCycleHalfYearly,
localization.yearly,
];
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../theme/app_colors.dart'; import '../../../theme/app_colors.dart';
import '../../../providers/category_provider.dart';
/// 카테고리 선택 위젯 /// 카테고리 선택 위젯
/// 구독 서비스의 카테고리를 선택할 수 있습니다. /// 구독 서비스의 카테고리를 선택할 수 있습니다.
@@ -50,13 +52,17 @@ class CategorySelector extends StatelessWidget {
color: _getTextColor(isSelected), color: _getTextColor(isSelected),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Consumer<CategoryProvider>(
category.name, builder: (context, categoryProvider, child) {
return Text(
categoryProvider.getLocalizedCategoryName(context, category.name),
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: _getTextColor(isSelected), color: _getTextColor(isSelected),
), ),
);
},
), ),
], ],
), ),
@@ -69,25 +75,25 @@ class CategorySelector extends StatelessWidget {
IconData _getCategoryIcon(dynamic category) { IconData _getCategoryIcon(dynamic category) {
// 카테고리명에 따른 아이콘 반환 // 카테고리명에 따른 아이콘 반환
switch (category.name) { switch (category.name) {
case '음악': case 'music':
return Icons.music_note_rounded; return Icons.music_note_rounded;
case 'OTT(동영상)': case 'ottVideo':
return Icons.movie_filter_rounded; return Icons.movie_filter_rounded;
case '저장/클라우드': case 'storageCloud':
return Icons.cloud_outlined; return Icons.cloud_outlined;
case '통신 · 인터넷 · TV': case 'telecomInternetTv':
return Icons.wifi_rounded; return Icons.wifi_rounded;
case '생활/라이프스타일': case 'lifestyle':
return Icons.home_outlined; return Icons.home_outlined;
case '쇼핑/이커머스': case 'shoppingEcommerce':
return Icons.shopping_cart_outlined; return Icons.shopping_cart_outlined;
case '프로그래밍': case 'programming':
return Icons.code_rounded; return Icons.code_rounded;
case '협업/오피스': case 'collaborationOffice':
return Icons.business_center_outlined; return Icons.business_center_outlined;
case 'AI 서비스': case 'aiService':
return Icons.smart_toy_outlined; return Icons.smart_toy_outlined;
case '기타': case 'other':
default: default:
return Icons.category_outlined; return Icons.category_outlined;
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'base_text_field.dart'; import 'base_text_field.dart';
import '../../../l10n/app_localizations.dart';
/// 통화 입력 필드 위젯 /// 통화 입력 필드 위젯
/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다. /// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다.
@@ -112,8 +113,8 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
return widget.currency == 'KRW' ? '' : '\$ '; return widget.currency == 'KRW' ? '' : '\$ ';
} }
String get _defaultHintText { String _getDefaultHintText(BuildContext context) {
return widget.currency == 'KRW' ? '금액을 입력하세요' : 'Enter amount'; return AppLocalizations.of(context).enterAmount;
} }
@override @override
@@ -122,7 +123,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
controller: widget.controller, controller: widget.controller,
focusNode: _focusNode, focusNode: _focusNode,
label: widget.label, label: widget.label,
hintText: widget.hintText ?? _defaultHintText, hintText: widget.hintText ?? _getDefaultHintText(context),
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [ inputFormatters: [
@@ -158,11 +159,11 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
}, },
validator: widget.validator ?? (value) { validator: widget.validator ?? (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '금액을 입력해주세요'; return AppLocalizations.of(context).amountRequired;
} }
final parsedValue = _parseValue(value); final parsedValue = _parseValue(value);
if (parsedValue == null || parsedValue <= 0) { if (parsedValue == null || parsedValue <= 0) {
return '올바른 금액을 입력해주세요'; return AppLocalizations.of(context).invalidAmount;
} }
return null; return null;
}, },

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart'; import '../../../theme/app_colors.dart';
/// 통화 선택 위젯 /// 통화 선택 위젯
/// KRW(원화) USD(달러) 중 선택할 수 있습니다. /// 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;
@@ -17,7 +17,9 @@ class CurrencySelector extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Column(
children: [
Row(
children: [ children: [
_CurrencyOption( _CurrencyOption(
label: '', label: '',
@@ -35,6 +37,30 @@ class CurrencySelector extends StatelessWidget {
isGlassmorphism: isGlassmorphism, isGlassmorphism: isGlassmorphism,
), ),
], ],
),
const SizedBox(height: 8),
Row(
children: [
_CurrencyOption(
label: '¥',
value: 'JPY',
subtitle: 'JPY',
isSelected: currency == 'JPY',
onTap: () => onChanged('JPY'),
isGlassmorphism: isGlassmorphism,
),
const SizedBox(width: 8),
_CurrencyOption(
label: '¥',
value: 'CNY',
subtitle: 'CNY',
isSelected: currency == 'CNY',
onTap: () => onChanged('CNY'),
isGlassmorphism: isGlassmorphism,
),
],
),
],
); );
} }
} }
@@ -43,6 +69,7 @@ class CurrencySelector extends StatelessWidget {
class _CurrencyOption extends StatelessWidget { class _CurrencyOption extends StatelessWidget {
final String label; final String label;
final String value; final String value;
final String? subtitle;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final bool isGlassmorphism; final bool isGlassmorphism;
@@ -50,6 +77,7 @@ class _CurrencyOption extends StatelessWidget {
const _CurrencyOption({ const _CurrencyOption({
required this.label, required this.label,
required this.value, required this.value,
this.subtitle,
required this.isSelected, required this.isSelected,
required this.onTap, required this.onTap,
required this.isGlassmorphism, required this.isGlassmorphism,
@@ -71,7 +99,10 @@ class _CurrencyOption extends StatelessWidget {
border: _getBorder(), border: _getBorder(),
), ),
child: Center( child: Center(
child: Text( child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label, label,
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
@@ -79,6 +110,19 @@ class _CurrencyOption extends StatelessWidget {
color: _getTextColor(), color: _getTextColor(),
), ),
), ),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: _getTextColor().withValues(alpha: 0.8),
),
),
],
],
),
), ),
), ),
), ),

View File

@@ -1,6 +1,7 @@
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';
/// 날짜 선택 필드 위젯 /// 날짜 선택 필드 위젯
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다. /// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
@@ -38,7 +39,9 @@ class DatePickerField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectivePrimaryColor = primaryColor ?? theme.primaryColor; final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
final effectiveDateFormat = dateFormat ?? 'yyyy년 MM월 dd일'; final localizations = AppLocalizations.of(context);
final effectiveDateFormat = dateFormat ?? localizations.dateFormatFull;
final locale = Localizations.localeOf(context);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -94,7 +97,7 @@ class DatePickerField extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
DateFormat(effectiveDateFormat).format(selectedDate), DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: enabled color: enabled
@@ -249,8 +252,8 @@ class _DateRangeItem extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
date != null date != null
? DateFormat('MM/dd').format(date!) ? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!)
: '선택', : AppLocalizations.of(context).dateSelect,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../common/buttons/primary_button.dart'; import '../common/buttons/primary_button.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 액션 버튼 섹션 /// 상세 화면 액션 버튼 섹션
/// 저장 버튼을 포함하는 섹션입니다. /// 저장 버튼을 포함하는 섹션입니다.
@@ -33,7 +34,7 @@ class DetailActionButtons extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 80), padding: const EdgeInsets.only(bottom: 80),
child: PrimaryButton( child: PrimaryButton(
text: '변경사항 저장', text: AppLocalizations.of(context).saveChanges,
icon: Icons.save_rounded, icon: Icons.save_rounded,
onPressed: controller.updateSubscription, onPressed: controller.updateSubscription,
isLoading: controller.isLoading, isLoading: controller.isLoading,

View File

@@ -4,6 +4,7 @@ import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.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';
/// 이벤트 가격 섹션 /// 이벤트 가격 섹션
/// 할인 이벤트 정보를 관리하는 섹션입니다. /// 할인 이벤트 정보를 관리하는 섹션입니다.
@@ -75,9 +76,9 @@ class DetailEventSection extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Text(
'이벤트 가격', AppLocalizations.of(context).eventPrice,
style: TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -125,7 +126,7 @@ class DetailEventSection extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'할인 또는 프로모션 가격을 설정하세요', AppLocalizations.of(context).eventPriceHint,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -151,8 +152,8 @@ class DetailEventSection extends StatelessWidget {
onEndDateSelected: (date) { onEndDateSelected: (date) {
controller.eventEndDate = date; controller.eventEndDate = date;
}, },
startLabel: '시작일', startLabel: AppLocalizations.of(context).startDate,
endLabel: '종료일', endLabel: AppLocalizations.of(context).endDate,
primaryColor: baseColor, primaryColor: baseColor,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -160,16 +161,16 @@ class DetailEventSection extends StatelessWidget {
CurrencyInputField( CurrencyInputField(
controller: controller.eventPriceController, controller: controller.eventPriceController,
currency: controller.currency, currency: controller.currency,
label: '이벤트 가격', label: AppLocalizations.of(context).eventPrice,
hintText: '할인된 가격을 입력하세요', hintText: AppLocalizations.of(context).eventPriceHint,
validator: controller.isEventActive validator: controller.isEventActive
? (value) { ? (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '이벤트 가격을 입력해주세요'; return AppLocalizations.of(context).eventPriceRequired;
} }
final price = double.tryParse(value.replaceAll(',', '')); final price = double.tryParse(value.replaceAll(',', ''));
if (price == null || price <= 0) { if (price == null || price <= 0) {
return '올바른 가격을 입력해주세요'; return AppLocalizations.of(context).invalidPrice;
} }
return null; return null;
} }
@@ -233,7 +234,7 @@ class _DiscountBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
'$discountPercentage% 할인', AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 12, fontSize: 12,
@@ -243,9 +244,7 @@ class _DiscountBadge extends StatelessWidget {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
currency == 'KRW' _getLocalizedDiscountAmount(context, currency, discountAmount),
? '${discountAmount.toInt().toString()}원 절약'
: '\$${discountAmount.toStringAsFixed(2)} 절약',
style: TextStyle( style: TextStyle(
color: const Color(0xFF15803D), color: const Color(0xFF15803D),
fontSize: 14, fontSize: 14,
@@ -256,4 +255,18 @@ class _DiscountBadge extends StatelessWidget {
), ),
); );
} }
String _getLocalizedDiscountAmount(BuildContext context, String currency, double amount) {
final loc = AppLocalizations.of(context);
switch (currency) {
case 'KRW':
return loc.discountAmountWon.replaceAll('@', amount.toInt().toString());
case 'JPY':
return loc.discountAmountYen.replaceAll('@', amount.toInt().toString());
case 'CNY':
return loc.discountAmountYuan.replaceAll('@', amount.toStringAsFixed(2));
default: // USD
return loc.discountAmountDollar.replaceAll('@', amount.toStringAsFixed(2));
}
}
} }

View File

@@ -9,6 +9,7 @@ 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/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';
/// 상세 화면 폼 섹션 /// 상세 화면 폼 섹션
/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다. /// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다.
@@ -66,8 +67,8 @@ class DetailFormSection extends StatelessWidget {
BaseTextField( BaseTextField(
controller: controller.serviceNameController, controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus, focusNode: controller.serviceNameFocus,
label: '서비스명', label: AppLocalizations.of(context).subscriptionName,
hintText: '예: Netflix, Spotify', hintText: AppLocalizations.of(context).serviceNameExample,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onEditingComplete: () { onEditingComplete: () {
controller.monthlyCostFocus.requestFocus(); controller.monthlyCostFocus.requestFocus();
@@ -84,7 +85,7 @@ class DetailFormSection extends StatelessWidget {
child: CurrencyInputField( child: CurrencyInputField(
controller: controller.monthlyCostController, controller: controller.monthlyCostController,
currency: controller.currency, currency: controller.currency,
label: '월 지출', label: AppLocalizations.of(context).monthlyExpense,
focusNode: controller.monthlyCostFocus, focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onEditingComplete: () { onEditingComplete: () {
@@ -97,9 +98,9 @@ class DetailFormSection extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'통화', AppLocalizations.of(context).currency,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -134,9 +135,9 @@ class DetailFormSection extends StatelessWidget {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'결제 주기', AppLocalizations.of(context).billingCycle,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -161,7 +162,7 @@ class DetailFormSection extends StatelessWidget {
onDateSelected: (date) { onDateSelected: (date) {
controller.nextBillingDate = date; controller.nextBillingDate = date;
}, },
label: '다음 결제일', label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(), firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)), lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor, primaryColor: baseColor,
@@ -174,9 +175,9 @@ class DetailFormSection extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'카테고리', AppLocalizations.of(context).category,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,

View File

@@ -3,7 +3,10 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart';
import '../../services/currency_util.dart';
import '../website_icon.dart'; import '../website_icon.dart';
import '../../l10n/app_localizations.dart';
/// 상세 화면 상단 헤더 섹션 /// 상세 화면 상단 헤더 섹션
/// 서비스 아이콘, 이름, 결제 정보를 표시합니다. /// 서비스 아이콘, 이름, 결제 정보를 표시합니다.
@@ -134,7 +137,7 @@ class DetailHeaderSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
controller.serviceNameController.text, controller.displayName ?? controller.serviceNameController.text,
style: const TextStyle( style: const TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@@ -151,7 +154,8 @@ class DetailHeaderSection extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${controller.billingCycle} 결제', AppLocalizations.of(context).billingCyclePayment.replaceAll('@',
_getLocalizedBillingCycle(context, controller.billingCycle)),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -175,25 +179,28 @@ class DetailHeaderSection extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_InfoColumn( _InfoColumn(
label: '다음 결제일', label: AppLocalizations.of(context).nextBillingDate,
value: DateFormat('yyyy년 MM월 dd일') value: AppLocalizations.of(context).formatDate(controller.nextBillingDate),
.format(controller.nextBillingDate),
), ),
_InfoColumn( FutureBuilder<String>(
label: '월 지출', future: () async {
value: NumberFormat.currency( final locale = context.read<LocaleProvider>().locale.languageCode;
locale: controller.currency == 'KRW' final amount = double.tryParse(
? 'ko_KR'
: 'en_US',
symbol: controller.currency == 'KRW'
? ''
: '\$',
decimalDigits:
controller.currency == 'KRW' ? 0 : 2,
).format(double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '') controller.monthlyCostController.text.replaceAll(',', '')
) ?? 0), ) ?? 0;
return CurrencyUtil.formatAmountWithLocale(
amount,
controller.currency,
locale,
);
}(),
builder: (context, snapshot) {
return _InfoColumn(
label: AppLocalizations.of(context).monthlyExpense,
value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end, alignment: CrossAxisAlignment.end,
);
},
), ),
], ],
), ),
@@ -212,6 +219,33 @@ class DetailHeaderSection extends StatelessWidget {
}, },
); );
} }
String _getLocalizedBillingCycle(BuildContext context, String cycle) {
final loc = AppLocalizations.of(context);
switch (cycle.toLowerCase()) {
case '매월':
case 'monthly':
case '毎月':
case '每月':
return loc.billingCycleMonthly;
case '분기별':
case 'quarterly':
case '四半期':
case '每季度':
return loc.billingCycleQuarterly;
case '반기별':
case 'half-yearly':
case '半年ごと':
case '每半年':
return loc.billingCycleHalfYearly;
case '매년':
case 'yearly':
case '年間':
case '每年':
return loc.billingCycleYearly;
default:
return cycle;
}
}
} }
/// 정보 표시 컬럼 /// 정보 표시 컬럼

View File

@@ -3,6 +3,7 @@ import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.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';
/// 웹사이트 URL 섹션 /// 웹사이트 URL 섹션
/// 서비스 웹사이트 URL과 해지 관련 정보를 관리하는 섹션입니다. /// 서비스 웹사이트 URL과 해지 관련 정보를 관리하는 섹션입니다.
@@ -69,9 +70,9 @@ class DetailUrlSection extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
const Text( Text(
'웹사이트 정보', AppLocalizations.of(context).websiteInfo,
style: TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -85,8 +86,8 @@ class DetailUrlSection extends StatelessWidget {
BaseTextField( BaseTextField(
controller: controller.websiteUrlController, controller: controller.websiteUrlController,
focusNode: controller.websiteUrlFocus, focusNode: controller.websiteUrlFocus,
label: '웹사이트 URL', label: AppLocalizations.of(context).websiteUrl,
hintText: 'https://example.com', hintText: AppLocalizations.of(context).urlExample,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: Icon( prefixIcon: Icon(
Icons.link_rounded, Icons.link_rounded,
@@ -120,7 +121,7 @@ class DetailUrlSection extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'해지 안내', AppLocalizations.of(context).cancelGuide,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -131,7 +132,7 @@ class DetailUrlSection extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.', AppLocalizations.of(context).cancelServiceGuide,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -141,7 +142,7 @@ class DetailUrlSection extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextLinkButton( TextLinkButton(
text: '해지 페이지로 이동', 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: AppColors.warningColor,
@@ -174,7 +175,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다', AppLocalizations.of(context).urlAutoMatchInfo,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,

View File

@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'glassmorphism_card.dart'; import 'glassmorphism_card.dart';
import 'themed_text.dart'; import 'themed_text.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../l10n/app_localizations.dart';
/// 구독이 없을 때 표시되는 빈 화면 위젯 /// 구독이 없을 때 표시되는 빈 화면 위젯
/// ///
@@ -74,15 +75,15 @@ class EmptyStateWidget extends StatelessWidget {
}, },
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
const ThemedText( ThemedText(
'등록된 구독이 없습니다', AppLocalizations.of(context).noSubscriptions,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: -0.5, letterSpacing: -0.5,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const ThemedText( ThemedText(
'새로운 구독을 추가해보세요', AppLocalizations.of(context).addSubscriptionNow,
fontSize: 16, fontSize: 16,
opacity: 0.7, opacity: 0.7,
), ),
@@ -107,8 +108,8 @@ class EmptyStateWidget extends StatelessWidget {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
onAddPressed(); onAddPressed();
}, },
child: const Text( child: Text(
'구독 추가하기', AppLocalizations.of(context).addSubscription,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@@ -2,6 +2,7 @@ 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 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart';
class FloatingNavigationBar extends StatefulWidget { class FloatingNavigationBar extends StatefulWidget {
final int selectedIndex; final int selectedIndex;
@@ -103,13 +104,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
children: [ children: [
_NavigationItem( _NavigationItem(
icon: Icons.home_rounded, icon: Icons.home_rounded,
label: '', label: AppLocalizations.of(context).home,
isSelected: widget.selectedIndex == 0, isSelected: widget.selectedIndex == 0,
onTap: () => _onItemTapped(0), onTap: () => _onItemTapped(0),
), ),
_NavigationItem( _NavigationItem(
icon: Icons.analytics_rounded, icon: Icons.analytics_rounded,
label: '분석', label: AppLocalizations.of(context).analysis,
isSelected: widget.selectedIndex == 1, isSelected: widget.selectedIndex == 1,
onTap: () => _onItemTapped(1), onTap: () => _onItemTapped(1),
), ),
@@ -118,13 +119,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
), ),
_NavigationItem( _NavigationItem(
icon: Icons.qr_code_scanner_rounded, icon: Icons.qr_code_scanner_rounded,
label: 'SMS', label: AppLocalizations.of(context).smsScanLabel,
isSelected: widget.selectedIndex == 3, isSelected: widget.selectedIndex == 3,
onTap: () => _onItemTapped(3), onTap: () => _onItemTapped(3),
), ),
_NavigationItem( _NavigationItem(
icon: Icons.settings_rounded, icon: Icons.settings_rounded,
label: '설정', label: AppLocalizations.of(context).settings,
isSelected: widget.selectedIndex == 4, isSelected: widget.selectedIndex == 4,
onTap: () => _onItemTapped(4), onTap: () => _onItemTapped(4),
), ),

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'dart:ui'; import 'dart:ui';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'themed_text.dart'; import 'themed_text.dart';
import '../l10n/app_localizations.dart';
/// 글래스모피즘 효과가 적용된 통일된 앱바 /// 글래스모피즘 효과가 적용된 통일된 앱바
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget { class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
@@ -113,7 +114,7 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
splashRadius: 24, splashRadius: 24,
tooltip: '뒤로가기', tooltip: AppLocalizations.of(context).back,
color: ThemedText.getContrastColor(context), color: ThemedText.getContrastColor(context),
); );
} }
@@ -221,7 +222,7 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
splashRadius: 24, splashRadius: 24,
tooltip: '뒤로가기', tooltip: AppLocalizations.of(context).back,
) )
: null), : null),
actions: actions, actions: actions,

View File

@@ -173,8 +173,23 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
@override @override
Widget build(BuildContext context) { 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( return GestureDetector(
behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지 behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown, onTapDown: _handleTapDown,
onTapUp: (details) { onTapUp: (details) {
_handleTapUp(details); _handleTapUp(details);

View File

@@ -8,6 +8,7 @@ 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';
class HomeContent extends StatelessWidget { class HomeContent extends StatelessWidget {
final AnimationController fadeController; final AnimationController fadeController;
@@ -55,6 +56,7 @@ class HomeContent extends StatelessWidget {
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions( final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions, provider.subscriptions,
categoryProvider, categoryProvider,
context,
); );
return RefreshIndicator( return RefreshIndicator(
@@ -103,7 +105,7 @@ class HomeContent extends StatelessWidget {
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)), parent: slideController, curve: Curves.easeOutCubic)),
child: Text( child: Text(
'나의 구독 서비스', AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),
@@ -118,7 +120,7 @@ class HomeContent extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
'${provider.subscriptions.length}', AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length),
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'animated_wave_background.dart'; import 'animated_wave_background.dart';
import 'glassmorphism_card.dart'; import 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart';
/// 메인 화면 상단에 표시되는 요약 카드 위젯 /// 메인 화면 상단에 표시되는 요약 카드 위젯
/// ///
@@ -26,10 +29,12 @@ class MainScreenSummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double monthlyCost = provider.totalMonthlyExpense; // 언어 설정 가져오기
final double yearlyCost = monthlyCost * 12; final locale = context.watch<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
final currencySymbol = CurrencyUtil.getCurrencySymbol(defaultCurrency);
final int totalSubscriptions = provider.subscriptions.length; final int totalSubscriptions = provider.subscriptions.length;
final double eventSavings = provider.totalEventSavings;
final int activeEvents = provider.activeEventSubscriptions.length; final int activeEvents = provider.activeEventSubscriptions.length;
return FadeTransition( return FadeTransition(
@@ -83,7 +88,7 @@ class MainScreenSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'이번 달 총 구독 비용', AppLocalizations.of(context).monthlyTotalSubscriptionCost,
style: TextStyle( style: TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
@@ -91,8 +96,10 @@ class MainScreenSummaryCard extends StatelessWidget {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
// 환율 정보 표시 (영어 사용자는 표시 안함)
if (locale != 'en')
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfo(), future: CurrencyUtil.getExchangeRateInfoForLocale(locale),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) { if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return Container( return Container(
@@ -109,7 +116,7 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
), ),
child: Text( child: Text(
snapshot.data!, AppLocalizations.of(context).exchangeRateDisplay.replaceAll('@', snapshot.data!),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -124,19 +131,33 @@ class MainScreenSummaryCard extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( // 월별 총 비용 표시 (언어별 기본 통화)
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions,
locale,
),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
final monthlyCost = snapshot.data!;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
return Row(
crossAxisAlignment: CrossAxisAlignment.baseline, crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic, textBaseline: TextBaseline.alphabetic,
children: [ children: [
Text( Text(
NumberFormat.currency( NumberFormat.currency(
locale: 'ko_KR', locale: defaultCurrency == 'KRW' ? 'ko_KR' :
defaultCurrency == 'JPY' ? 'ja_JP' :
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
symbol: '', symbol: '',
decimalDigits: 0, decimalDigits: decimals,
).format(monthlyCost), ).format(monthlyCost),
style: const TextStyle( style: const TextStyle(
color: AppColors color: AppColors.darkNavy,
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: -1, letterSpacing: -1,
@@ -144,35 +165,54 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'', currencySymbol,
style: TextStyle( style: const TextStyle(
color: AppColors color: AppColors.darkNavy,
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
], ],
);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( // 연간 비용 및 총 구독 수 표시
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions,
locale,
),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
final monthlyCost = snapshot.data!;
final yearlyCost = monthlyCost * 12;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
return Row(
children: [ children: [
_buildInfoBox( _buildInfoBox(
context, context,
title: '예상 연간 구독 비용', title: AppLocalizations.of(context).estimatedAnnualCost,
value: '${NumberFormat.currency( value: '${NumberFormat.currency(
locale: 'ko_KR', locale: defaultCurrency == 'KRW' ? 'ko_KR' :
symbol: '', defaultCurrency == 'JPY' ? 'ja_JP' :
decimalDigits: 0, defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
).format(yearlyCost)}', symbol: currencySymbol,
decimalDigits: decimals,
).format(yearlyCost)}',
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildInfoBox( _buildInfoBox(
context, context,
title: '총 구독 서비스', title: AppLocalizations.of(context).totalSubscriptionServices,
value: '$totalSubscriptions', value: '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
), ),
], ],
);
},
), ),
// 이벤트 절약액 표시 // 이벤트 절약액 표시
if (activeEvents > 0) ...[ if (activeEvents > 0) ...[
@@ -215,7 +255,7 @@ class MainScreenSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'이벤트 할인 중', AppLocalizations.of(context).eventDiscountActive,
style: TextStyle( style: TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
@@ -224,31 +264,46 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row( // 이벤트 절약액 표시 (언어별 기본 통화)
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalEventSavingsInDefaultCurrency(
provider.subscriptions,
locale,
),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
final eventSavings = snapshot.data!;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
return Row(
children: [ children: [
Text( Text(
NumberFormat.currency( NumberFormat.currency(
locale: 'ko_KR', locale: defaultCurrency == 'KRW' ? 'ko_KR' :
symbol: '', defaultCurrency == 'JPY' ? 'ja_JP' :
decimalDigits: 0, defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
symbol: currencySymbol,
decimalDigits: decimals,
).format(eventSavings), ).format(eventSavings),
style: const TextStyle( style: const TextStyle(
color: AppColors color: AppColors.primaryColor,
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text( Text(
' 절약 ($activeEvents개)', ' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
style: TextStyle( style: const TextStyle(
color: AppColors color: AppColors.navyGray,
.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
], ],
);
},
), ),
], ],
), ),

View File

@@ -3,10 +3,14 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/currency_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 'glassmorphism_card.dart';
import '../l10n/app_localizations.dart';
class SubscriptionCard extends StatefulWidget { class SubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription; final SubscriptionModel subscription;
@@ -26,6 +30,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _hoverController; late AnimationController _hoverController;
bool _isHovering = false; bool _isHovering = false;
String? _displayName;
@override @override
void initState() { void initState() {
@@ -34,8 +39,35 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
); );
_loadDisplayName();
} }
Future<void> _loadDisplayName() async {
if (!mounted) return;
final localeProvider = context.read<LocaleProvider>();
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: widget.subscription.serviceName,
locale: locale,
);
if (mounted) {
setState(() {
_displayName = displayName;
});
}
}
@override
void didUpdateWidget(SubscriptionCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.subscription.serviceName != widget.subscription.serviceName) {
_loadDisplayName();
}
}
@override @override
void dispose() { void dispose() {
@@ -66,20 +98,20 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 오늘이 결제일인 경우 // 오늘이 결제일인 경우
if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) { if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) {
return '오늘 결제 예정'; return AppLocalizations.of(context).paymentDueToday;
} }
// 미래 날짜인 경우 남은 일수 계산 // 미래 날짜인 경우 남은 일수 계산
if (dateOnlyBilling.isAfter(dateOnlyNow)) { if (dateOnlyBilling.isAfter(dateOnlyNow)) {
final difference = dateOnlyBilling.difference(dateOnlyNow).inDays; final difference = dateOnlyBilling.difference(dateOnlyNow).inDays;
return '$difference일 후 결제 예정'; return AppLocalizations.of(context).paymentDueInDays(difference);
} }
// 과거 날짜인 경우, 다음 결제일 계산 // 과거 날짜인 경우, 다음 결제일 계산
final billingCycle = widget.subscription.billingCycle; final billingCycle = widget.subscription.billingCycle;
// 월간 구독인 경우 // 월간 구독인 경우
if (billingCycle == '월간') { if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') {
// 결제일에 해당하는 날짜 가져오기 // 결제일에 해당하는 날짜 가져오기
int day = nextBillingDate.day; int day = nextBillingDate.day;
int nextMonth = now.month; int nextMonth = now.month;
@@ -109,12 +141,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
final nextDate = DateTime(nextYear, nextMonth, day); final nextDate = DateTime(nextYear, nextMonth, day);
final days = nextDate.difference(dateOnlyNow).inDays; final days = nextDate.difference(dateOnlyNow).inDays;
if (days == 0) return '오늘 결제 예정'; if (days == 0) return AppLocalizations.of(context).paymentDueToday;
return '$days일 후 결제 예정'; return AppLocalizations.of(context).paymentDueInDays(days);
} }
// 연간 구독인 경우 // 연간 구독인 경우
if (billingCycle == '연간') { if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'yearly') {
// 결제일에 해당하는 날짜와 월 가져오기 // 결제일에 해당하는 날짜와 월 가져오기
int day = nextBillingDate.day; int day = nextBillingDate.day;
int month = nextBillingDate.month; int month = nextBillingDate.month;
@@ -143,18 +175,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
final nextYearDate = DateTime(year, month, day); final nextYearDate = DateTime(year, month, day);
final days = nextYearDate.difference(dateOnlyNow).inDays; final days = nextYearDate.difference(dateOnlyNow).inDays;
if (days == 0) return '오늘 결제 예정'; if (days == 0) return AppLocalizations.of(context).paymentDueToday;
return '$days일 후 결제 예정'; return AppLocalizations.of(context).paymentDueInDays(days);
} else { } else {
final days = thisYearDate.difference(dateOnlyNow).inDays; final days = thisYearDate.difference(dateOnlyNow).inDays;
if (days == 0) return '오늘 결제 예정'; if (days == 0) return AppLocalizations.of(context).paymentDueToday;
return '$days일 후 결제 예정'; return AppLocalizations.of(context).paymentDueInDays(days);
} }
} }
// 주간 구독인 경우 // 주간 구독인 경우
if (billingCycle == '주간') { if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'weekly') {
// 결제 요일 가져오기 // 결제 요일 가져오기
final billingWeekday = nextBillingDate.weekday; final billingWeekday = nextBillingDate.weekday;
// 현재 요일 // 현재 요일
@@ -171,20 +203,20 @@ class _SubscriptionCardState extends State<SubscriptionCard>
daysUntilNext = 7; // 다음 주 같은 요일 daysUntilNext = 7; // 다음 주 같은 요일
} }
if (daysUntilNext == 0) return '오늘 결제 예정'; if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday;
return '$daysUntilNext 후 결제 예정'; return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
} }
// 기본값 - 예상할 수 없는 경우 // 기본값 - 예상할 수 없는 경우
return '결제일 정보 필요'; return AppLocalizations.of(context).paymentInfoNeeded;
} }
// 결제일이 가까운지 확인 (7일 이내) // 결제일이 가까운지 확인 (7일 이내)
bool _isNearBilling() { bool _isNearBilling() {
final text = _getNextBillingText(); final text = _getNextBillingText();
if (text == '오늘 결제 예정') return true; if (text == AppLocalizations.of(context).paymentDueToday) return true;
final regex = RegExp(r'(\d+)일 후'); final regex = RegExp(r'(\d+)');
final match = regex.firstMatch(text); final match = regex.firstMatch(text);
if (match != null) { if (match != null) {
final days = int.parse(match.group(1) ?? '0'); final days = int.parse(match.group(1) ?? '0');
@@ -222,9 +254,41 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} }
} }
// 가격 포맷팅 함수 (언어별 통화)
Future<String> _getFormattedPrice() async {
final locale = context.read<LocaleProvider>().locale.languageCode;
if (widget.subscription.isCurrentlyInEvent) {
// 이벤트 중인 경우 원래 가격과 현재 가격 모두 표시
final originalPrice = await CurrencyUtil.formatAmountWithLocale(
widget.subscription.monthlyCost,
widget.subscription.currency,
locale,
);
final currentPrice = await CurrencyUtil.formatAmountWithLocale(
widget.subscription.currentPrice,
widget.subscription.currency,
locale,
);
return '$originalPrice|$currentPrice';
} else {
return CurrencyUtil.formatAmountWithLocale(
widget.subscription.currentPrice,
widget.subscription.currency,
locale,
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// LocaleProvider를 watch하여 언어 변경시 자동 업데이트
final localeProvider = context.watch<LocaleProvider>();
// 언어가 변경되면 displayName 다시 로드
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadDisplayName();
});
final isNearBilling = _isNearBilling(); final isNearBilling = _isNearBilling();
return Hero( return Hero(
@@ -238,6 +302,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
blur: _isHovering ? 15 : 10, blur: _isHovering ? 15 : 10,
width: double.infinity, // 전체 너비를 차지하도록 설정 width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ?? () async { onTap: widget.onTap ?? () async {
print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
await AppNavigator.toDetail(context, widget.subscription); await AppNavigator.toDetail(context, widget.subscription);
}, },
child: Column( child: Column(
@@ -290,7 +355,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 서비스명 // 서비스명
Flexible( Flexible(
child: Text( child: Text(
widget.subscription.serviceName, _displayName ?? widget.subscription.serviceName,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 18, fontSize: 18,
@@ -322,18 +387,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
borderRadius: borderRadius:
BorderRadius.circular(12), BorderRadius.circular(12),
), ),
child: const Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( const Icon(
Icons.local_offer_rounded, Icons.local_offer_rounded,
size: 11, size: 11,
color: AppColors.pureWhite, color: AppColors.pureWhite,
), ),
SizedBox(width: 3), const SizedBox(width: 3),
Text( Text(
'이벤트', AppLocalizations.of(context).event,
style: TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -361,7 +426,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
), ),
), ),
child: Text( child: Text(
widget.subscription.billingCycle, AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle),
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -382,48 +447,41 @@ class _SubscriptionCardState extends State<SubscriptionCard>
MainAxisAlignment.spaceBetween, MainAxisAlignment.spaceBetween,
children: [ children: [
// 가격 표시 (이벤트 가격 반영) // 가격 표시 (이벤트 가격 반영)
Row( // 가격 표시 (언어별 통화)
FutureBuilder<String>(
future: _getFormattedPrice(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
if (widget.subscription.isCurrentlyInEvent && snapshot.data!.contains('|')) {
final prices = snapshot.data!.split('|');
return Row(
children: [ children: [
// 이벤트 중인 경우 원래 가격을 취소선으로 표시
if (widget.subscription.isCurrentlyInEvent) ...[
Text( Text(
widget.subscription.currency == 'USD' prices[0],
? NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(widget
.subscription.monthlyCost)
: NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(widget
.subscription.monthlyCost),
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors.navyGray,
decoration: TextDecoration.lineThrough, decoration: TextDecoration.lineThrough,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
],
// 현재 가격 (이벤트 또는 정상 가격)
Text( Text(
widget.subscription.currency == 'USD' prices[1],
? NumberFormat.currency( style: const TextStyle(
locale: 'en_US', fontSize: 16,
symbol: '\$', fontWeight: FontWeight.w700,
decimalDigits: 2, color: Color(0xFFFF6B6B),
).format(widget ),
.subscription.currentPrice) ),
: NumberFormat.currency( ],
locale: 'ko_KR', );
symbol: '', } else {
decimalDigits: 0, return Text(
).format(widget snapshot.data!,
.subscription.currentPrice),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -431,8 +489,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
? const Color(0xFFFF6B6B) ? const Color(0xFFFF6B6B)
: AppColors.primaryColor, : AppColors.primaryColor,
), ),
), );
], }
},
), ),
// 결제 예정일 정보 // 결제 예정일 정보
@@ -505,23 +564,25 @@ class _SubscriptionCardState extends State<SubscriptionCard>
color: Color(0xFFFF6B6B), color: Color(0xFFFF6B6B),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( // 이벤트 절약액 표시 (언어별 통화)
widget.subscription.currency == 'USD' FutureBuilder<String>(
? '${NumberFormat.currency( future: CurrencyUtil.formatEventSavingsWithLocale(
locale: 'en_US', widget.subscription,
symbol: '\$', localeProvider.locale.languageCode,
decimalDigits: 2, ),
).format(widget.subscription.eventSavings)} 절약' builder: (context, snapshot) {
: '${NumberFormat.currency( if (!snapshot.hasData) {
locale: 'ko_KR', return const SizedBox();
symbol: '', }
decimalDigits: 0, return Text(
).format(widget.subscription.eventSavings)} 절약', '${snapshot.data!} ${AppLocalizations.of(context).saving}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Color(0xFFFF6B6B), color: Color(0xFFFF6B6B),
), ),
);
},
), ),
], ],
), ),
@@ -530,7 +591,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 이벤트 종료일까지 남은 일수 // 이벤트 종료일까지 남은 일수
if (widget.subscription.eventEndDate != null) ...[ if (widget.subscription.eventEndDate != null) ...[
Text( Text(
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음', AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays),
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 서브 텍스트

View File

@@ -6,8 +6,12 @@ import '../widgets/staggered_list_animation.dart';
import '../widgets/app_navigator.dart'; import '../widgets/app_navigator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart'; import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart'; import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯 /// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget { class SubscriptionListWidget extends StatelessWidget {
@@ -39,11 +43,17 @@ class SubscriptionListWidget extends StatelessWidget {
// 카테고리 헤더 // 카테고리 헤더
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: CategoryHeaderWidget( child: Consumer<CategoryProvider>(
categoryName: category, builder: (context, categoryProvider, child) {
return CategoryHeaderWidget(
categoryName: categoryProvider.getLocalizedCategoryName(context, category),
subscriptionCount: subscriptions.length, subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
);
},
), ),
), ),
// 카테고리별 구독 목록 // 카테고리별 구독 목록
@@ -89,10 +99,21 @@ class SubscriptionListWidget extends StatelessWidget {
AppNavigator.toDetail(context, subscriptions[subIndex]); AppNavigator.toDetail(context, subscriptions[subIndex]);
}, },
onDelete: () async { onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(
context,
listen: false,
);
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show( final shouldDelete = await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: subscriptions[subIndex].serviceName, serviceName: displayName,
); );
if (shouldDelete && context.mounted) { if (shouldDelete && context.mounted) {
@@ -108,7 +129,7 @@ class SubscriptionListWidget extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.', message: AppLocalizations.of(context).subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded, icon: Icons.delete_forever_rounded,
); );
} }

View File

@@ -122,26 +122,17 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
} }
void _handlePanEnd(DragEndDetails details) { void _handlePanEnd(DragEndDetails details) {
final duration = DateTime.now().difference(_startTime!);
final velocity = details.velocity.pixelsPerSecond.dx; final velocity = details.velocity.pixelsPerSecond.dx;
// 탭/스와이프 처리 분기 // 스와이프 처리만 수행 (탭은 SubscriptionCard에서 처리)
// 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리
if (_isValidTap &&
duration.inMilliseconds < _tapDurationMs &&
_currentOffset.abs() < _tapTolerance) {
_processTap();
return;
}
// 스와이프 처리
_processSwipe(velocity); _processSwipe(velocity);
} }
// 헬퍼 메서드 // 헬퍼 메서드
void _processTap() { void _processTap() {
print('[SwipeableSubscriptionCard] _processTap 호출됨');
if (widget.onTap != null) { if (widget.onTap != null) {
print('[SwipeableSubscriptionCard] onTap 콜백 실행');
widget.onTap!(); widget.onTap!();
} }
_animateToOffset(0); _animateToOffset(0);
@@ -151,6 +142,12 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
final extent = _currentOffset.abs(); final extent = _currentOffset.abs();
final deleteThreshold = _cardWidth * _deleteThresholdPercent; final deleteThreshold = _cardWidth * _deleteThresholdPercent;
// 아주 작은 움직임은 무시하고 원위치로 복귀
if (extent < _tapTolerance) {
_animateToOffset(0);
return;
}
if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) { if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) {
// 삭제 실행 // 삭제 실행
if (widget.onDelete != null) { if (widget.onDelete != null) {
@@ -261,7 +258,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
angle: _currentOffset / 2000, angle: _currentOffset / 2000,
child: SubscriptionCard( child: SubscriptionCard(
subscription: widget.subscription, subscription: widget.subscription,
onTap: widget.onTap, onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함
), ),
), ),
), ),
@@ -279,6 +276,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
onPanStart: _handlePanStart, onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate, onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd, onPanEnd: _handlePanEnd,
// onTap 제거 - SubscriptionCard의 AnimatedGlassmorphismCard에서 처리하도록 함
child: _buildCard(), child: _buildCard(),
), ),
], ],