diff --git a/assets/appicon/appicon.png b/assets/appicon/appicon.png new file mode 100644 index 0000000..586dc2a Binary files /dev/null and b/assets/appicon/appicon.png differ diff --git a/assets/data/subscription_services.json b/assets/data/subscription_services.json index 2c113db..992bec8 100644 --- a/assets/data/subscription_services.json +++ b/assets/data/subscription_services.json @@ -5,7 +5,10 @@ "nameEn": "Music Streaming", "services": { "spotify": { - "names": ["spotify", "스포티파이"], + "names": [ + "spotify", + "스포티파이" + ], "urls": { "kr": "https://www.spotify.com/kr/", "en": "https://www.spotify.com" @@ -14,10 +17,18 @@ "kr": "https://support.spotify.com/kr/article/premium-구독-취소/", "en": "https://support.spotify.com/us/article/cancel-premium-subscription/" }, - "domains": ["spotify"] + "domains": [ + "spotify" + ], + "nameKr": "스포티파이", + "nameEn": "Spotify" }, "apple_music": { - "names": ["apple music", "애플 뮤직", "애플뮤직"], + "names": [ + "apple music", + "애플 뮤직", + "애플뮤직" + ], "urls": { "kr": "https://www.apple.com/kr/apple-music/", "en": "https://music.apple.com" @@ -26,10 +37,19 @@ "kr": "https://support.apple.com/ko-kr/HT204939", "en": "https://support.apple.com/en-us/HT204939" }, - "domains": ["apple", "music.apple"] + "domains": [ + "apple", + "music.apple" + ], + "nameKr": "애플 뮤직", + "nameEn": "Apple Music" }, "youtube_music": { - "names": ["youtube music", "유튜브 뮤직", "유튜브뮤직"], + "names": [ + "youtube music", + "유튜브 뮤직", + "유튜브뮤직" + ], "urls": { "kr": "https://music.youtube.com", "en": "https://music.youtube.com" @@ -38,10 +58,18 @@ "kr": "https://support.google.com/youtubemusic/answer/6313533?hl=ko", "en": "https://support.google.com/youtubemusic/answer/6313533?hl=en" }, - "domains": ["youtube", "music.youtube"] + "domains": [ + "youtube", + "music.youtube" + ], + "nameKr": "유튜브 뮤직", + "nameEn": "YouTube Music" }, "melon": { - "names": ["melon", "멜론"], + "names": [ + "melon", + "멜론" + ], "urls": { "kr": "https://www.melon.com", "en": "https://www.melon.com" @@ -50,10 +78,18 @@ "kr": "https://help.melon.com/customer/faq/faq_view.htm?faqSeq=3701", "en": null }, - "domains": ["melon"] + "domains": [ + "melon" + ], + "nameKr": "멜론", + "nameEn": "Melon" }, "genie": { - "names": ["genie", "지니", "genie music"], + "names": [ + "genie", + "지니", + "genie music" + ], "urls": { "kr": "https://www.genie.co.kr", "en": "https://www.genie.co.kr" @@ -62,10 +98,17 @@ "kr": "https://help.genie.co.kr/customer/faq/faq_view.htm?faqSeq=1132", "en": null }, - "domains": ["genie"] + "domains": [ + "genie" + ], + "nameKr": "지니", + "nameEn": "Genie" }, "flo": { - "names": ["flo", "플로"], + "names": [ + "flo", + "플로" + ], "urls": { "kr": "https://www.music-flo.com", "en": "https://www.music-flo.com" @@ -74,10 +117,18 @@ "kr": null, "en": null }, - "domains": ["music-flo", "flo"] + "domains": [ + "music-flo", + "flo" + ], + "nameKr": "플로", + "nameEn": "FLO" }, "bugs": { - "names": ["bugs", "벅스"], + "names": [ + "bugs", + "벅스" + ], "urls": { "kr": "https://music.bugs.co.kr", "en": "https://music.bugs.co.kr" @@ -86,10 +137,17 @@ "kr": "https://help.bugs.co.kr/faq/faqDetail?faqId=1000000000000039", "en": null }, - "domains": ["bugs"] + "domains": [ + "bugs" + ], + "nameKr": "벅스", + "nameEn": "Bugs" }, "vibe": { - "names": ["vibe", "바이브"], + "names": [ + "vibe", + "바이브" + ], "urls": { "kr": "https://vibe.naver.com", "en": "https://vibe.naver.com" @@ -98,10 +156,17 @@ "kr": null, "en": null }, - "domains": ["vibe"] + "domains": [ + "vibe" + ], + "nameKr": "바이브", + "nameEn": "VIBE" }, "tidal": { - "names": ["tidal", "타이달"], + "names": [ + "tidal", + "타이달" + ], "urls": { "kr": "https://tidal.com/kr/", "en": "https://tidal.com" @@ -110,7 +175,11 @@ "kr": "https://support.tidal.com/hc/ko/articles/115003662529", "en": "https://support.tidal.com/hc/en-us/articles/115003662529-How-do-I-cancel-my-TIDAL-subscription-" }, - "domains": ["tidal"] + "domains": [ + "tidal" + ], + "nameKr": "타이달", + "nameEn": "TIDAL" } } }, @@ -119,7 +188,10 @@ "nameEn": "OTT Services", "services": { "netflix": { - "names": ["netflix", "넷플릭스"], + "names": [ + "netflix", + "넷플릭스" + ], "urls": { "kr": "https://www.netflix.com/kr/", "en": "https://www.netflix.com" @@ -128,10 +200,18 @@ "kr": "https://help.netflix.com/ko/node/407", "en": "https://help.netflix.com/en/node/407" }, - "domains": ["netflix"] + "domains": [ + "netflix" + ], + "nameKr": "넷플릭스", + "nameEn": "Netflix" }, "disney_plus": { - "names": ["disney+", "디즈니플러스", "disney plus"], + "names": [ + "disney+", + "디즈니플러스", + "disney plus" + ], "urls": { "kr": "https://www.disneyplus.com/kr", "en": "https://www.disneyplus.com" @@ -140,10 +220,18 @@ "kr": "https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979", "en": "https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979" }, - "domains": ["disneyplus"] + "domains": [ + "disneyplus" + ], + "nameKr": "디즈니플러스", + "nameEn": "Disney+" }, "apple_tv": { - "names": ["apple tv+", "애플 티비플러스", "애플티비"], + "names": [ + "apple tv+", + "애플 티비플러스", + "애플티비" + ], "urls": { "kr": "https://tv.apple.com/kr/", "en": "https://tv.apple.com" @@ -152,10 +240,17 @@ "kr": "https://support.apple.com/ko-kr/HT207043", "en": "https://support.apple.com/en-us/HT207043" }, - "domains": ["tv.apple"] + "domains": [ + "tv.apple" + ], + "nameKr": "애플 TV+", + "nameEn": "Apple TV+" }, "youtube_premium": { - "names": ["youtube premium", "유튜브 프리미엄"], + "names": [ + "youtube premium", + "유튜브 프리미엄" + ], "urls": { "kr": "https://www.youtube.com/premium?gl=KR", "en": "https://www.youtube.com/premium" @@ -164,10 +259,17 @@ "kr": "https://support.google.com/youtube/answer/6306271?hl=ko", "en": "https://support.google.com/youtube/answer/6306271?hl=en" }, - "domains": ["youtube"] + "domains": [ + "youtube" + ], + "nameKr": "유튜브 프리미엄", + "nameEn": "YouTube Premium" }, "tving": { - "names": ["tving", "티빙"], + "names": [ + "tving", + "티빙" + ], "urls": { "kr": "https://www.tving.com", "en": "https://www.tving.com" @@ -176,10 +278,17 @@ "kr": "https://www.tving.com/my/cancelMembership", "en": null }, - "domains": ["tving"] + "domains": [ + "tving" + ], + "nameKr": "티빙", + "nameEn": "TVING" }, "wavve": { - "names": ["wavve", "웨이브"], + "names": [ + "wavve", + "웨이브" + ], "urls": { "kr": "https://www.wavve.com", "en": "https://www.wavve.com" @@ -188,10 +297,17 @@ "kr": "https://www.wavve.com/my", "en": null }, - "domains": ["wavve"] + "domains": [ + "wavve" + ], + "nameKr": "웨이브", + "nameEn": "Wavve" }, "watcha": { - "names": ["watcha", "왓챠"], + "names": [ + "watcha", + "왓챠" + ], "urls": { "kr": "https://watcha.com", "en": "https://watcha.com" @@ -200,10 +316,18 @@ "kr": "https://watcha.com/settings/payment", "en": null }, - "domains": ["watcha"] + "domains": [ + "watcha" + ], + "nameKr": "왓챠", + "nameEn": "Watcha" }, "coupang_play": { - "names": ["coupang play", "쿠팡 플레이", "쿠팡플레이"], + "names": [ + "coupang play", + "쿠팡 플레이", + "쿠팡플레이" + ], "urls": { "kr": "https://www.coupangplay.com", "en": "https://www.coupangplay.com" @@ -212,10 +336,19 @@ "kr": null, "en": null }, - "domains": ["coupangplay", "play.coupangplay"] + "domains": [ + "coupangplay", + "play.coupangplay" + ], + "nameKr": "쿠팡 플레이", + "nameEn": "Coupang Play" }, "amazon_prime": { - "names": ["amazon prime", "아마존 프라임", "prime video"], + "names": [ + "amazon prime", + "아마존 프라임", + "prime video" + ], "urls": { "kr": "https://www.primevideo.com", "en": "https://www.primevideo.com" @@ -224,10 +357,18 @@ "kr": "https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M", "en": "https://www.amazon.com/gp/primecentral/managemembership" }, - "domains": ["primevideo", "amazon"] + "domains": [ + "primevideo", + "amazon" + ], + "nameKr": "아마존 프라임", + "nameEn": "Amazon Prime" }, "hulu": { - "names": ["hulu", "훌루"], + "names": [ + "hulu", + "훌루" + ], "urls": { "kr": "https://www.hulu.com", "en": "https://www.hulu.com" @@ -236,7 +377,11 @@ "kr": "https://help.hulu.com/hc/ko/articles/360001164823", "en": "https://help.hulu.com/s/article/how-do-i-cancel" }, - "domains": ["hulu"] + "domains": [ + "hulu" + ], + "nameKr": "훌루", + "nameEn": "hulu" } } }, @@ -245,7 +390,11 @@ "nameEn": "Storage/Cloud", "services": { "google_drive": { - "names": ["google drive", "구글 드라이브", "구글드라이브"], + "names": [ + "google drive", + "구글 드라이브", + "구글드라이브" + ], "urls": { "kr": "https://www.google.com/drive/", "en": "https://www.google.com/drive/" @@ -254,10 +403,18 @@ "kr": "https://support.google.com/drive/answer/2375082?hl=ko", "en": "https://support.google.com/drive/answer/2375082?hl=en" }, - "domains": ["drive.google", "google"] + "domains": [ + "drive.google", + "google" + ], + "nameKr": "구글 드라이브", + "nameEn": "Google Drive" }, "dropbox": { - "names": ["dropbox", "드롭박스"], + "names": [ + "dropbox", + "드롭박스" + ], "urls": { "kr": "https://www.dropbox.com", "en": "https://www.dropbox.com" @@ -266,10 +423,18 @@ "kr": "https://help.dropbox.com/plans/downgrade-dropbox-individual-plans", "en": "https://help.dropbox.com/plans/downgrade-dropbox-individual-plans" }, - "domains": ["dropbox"] + "domains": [ + "dropbox" + ], + "nameKr": "드롭박스", + "nameEn": "Dropbox" }, "onedrive": { - "names": ["onedrive", "원드라이브", "microsoft onedrive"], + "names": [ + "onedrive", + "원드라이브", + "microsoft onedrive" + ], "urls": { "kr": "https://www.onedrive.com", "en": "https://www.onedrive.com" @@ -278,10 +443,17 @@ "kr": null, "en": "https://support.microsoft.com/en-us/office/cancel-your-microsoft-365-subscription-" }, - "domains": ["onedrive"] + "domains": [ + "onedrive" + ], + "nameKr": "원드라이브", + "nameEn": "OneDrive" }, "icloud": { - "names": ["icloud", "아이클라우드"], + "names": [ + "icloud", + "아이클라우드" + ], "urls": { "kr": "https://www.icloud.com", "en": "https://www.icloud.com" @@ -290,10 +462,17 @@ "kr": "https://support.apple.com/ko-kr/HT207594", "en": "https://support.apple.com/en-us/HT207594" }, - "domains": ["icloud"] + "domains": [ + "icloud" + ], + "nameKr": "아이클라우드", + "nameEn": "iCloud" }, "google_one": { - "names": ["google one", "구글 원"], + "names": [ + "google one", + "구글 원" + ], "urls": { "kr": "https://one.google.com", "en": "https://one.google.com" @@ -302,10 +481,18 @@ "kr": "https://support.google.com/googleone/answer/9140429", "en": "https://support.google.com/googleone/answer/9140429" }, - "domains": ["one.google"] + "domains": [ + "one.google" + ], + "nameKr": "구글 원", + "nameEn": "Google One" }, "naver_mybox": { - "names": ["naver mybox", "네이버 마이박스", "마이박스"], + "names": [ + "naver mybox", + "네이버 마이박스", + "마이박스" + ], "urls": { "kr": "https://mybox.naver.com", "en": null @@ -314,7 +501,11 @@ "kr": "https://help.naver.com/service/5638/contents/10041?osType=PC", "en": null }, - "domains": ["mybox.naver"] + "domains": [ + "mybox.naver" + ], + "nameKr": "네이버 마이박스", + "nameEn": "naver mybox" } } }, @@ -323,7 +514,11 @@ "nameEn": "AI Services", "services": { "chatgpt": { - "names": ["chatgpt", "챗GPT", "chatgpt plus"], + "names": [ + "chatgpt", + "챗GPT", + "chatgpt plus" + ], "urls": { "kr": "https://chat.openai.com", "en": "https://chat.openai.com" @@ -332,10 +527,19 @@ "kr": "https://help.openai.com/ko/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription", "en": "https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription" }, - "domains": ["chat.openai", "openai"] + "domains": [ + "chat.openai", + "openai" + ], + "nameKr": "챗GPT", + "nameEn": "ChatGPT" }, "claude": { - "names": ["claude", "클로드", "claude pro"], + "names": [ + "claude", + "클로드", + "claude pro" + ], "urls": { "kr": "https://claude.ai", "en": "https://claude.ai" @@ -344,10 +548,17 @@ "kr": "https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription", "en": "https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription" }, - "domains": ["claude"] + "domains": [ + "claude" + ], + "nameKr": "클로드", + "nameEn": "Claude" }, "midjourney": { - "names": ["midjourney", "미드저니"], + "names": [ + "midjourney", + "미드저니" + ], "urls": { "kr": "https://www.midjourney.com", "en": "https://www.midjourney.com" @@ -356,10 +567,18 @@ "kr": "https://docs.midjourney.com/docs/manage-subscription", "en": "https://docs.midjourney.com/docs/manage-subscription" }, - "domains": ["midjourney"] + "domains": [ + "midjourney" + ], + "nameKr": "미드저니", + "nameEn": "Midjourney" }, "perplexity": { - "names": ["perplexity", "퍼플렉시티", "perplexity pro"], + "names": [ + "perplexity", + "퍼플렉시티", + "perplexity pro" + ], "urls": { "kr": "https://www.perplexity.ai", "en": "https://www.perplexity.ai" @@ -368,10 +587,18 @@ "kr": null, "en": null }, - "domains": ["perplexity"] + "domains": [ + "perplexity" + ], + "nameKr": "퍼플렉시티", + "nameEn": "Perplexity" }, "copilot": { - "names": ["copilot", "코파일럿", "github copilot"], + "names": [ + "copilot", + "코파일럿", + "github copilot" + ], "urls": { "kr": "https://copilot.microsoft.com", "en": "https://copilot.microsoft.com" @@ -380,10 +607,18 @@ "kr": null, "en": null }, - "domains": ["copilot.microsoft"] + "domains": [ + "copilot.microsoft" + ], + "nameKr": "코파일럿", + "nameEn": "copilot" }, "gemini": { - "names": ["gemini", "제미니", "google gemini"], + "names": [ + "gemini", + "제미니", + "google gemini" + ], "urls": { "kr": "https://gemini.google.com", "en": "https://gemini.google.com" @@ -392,7 +627,11 @@ "kr": null, "en": null }, - "domains": ["gemini.google"] + "domains": [ + "gemini.google" + ], + "nameKr": "제미니", + "nameEn": "gemini" } } }, @@ -401,7 +640,10 @@ "nameEn": "Programming/Development", "services": { "github": { - "names": ["github", "깃허브"], + "names": [ + "github", + "깃허브" + ], "urls": { "kr": "https://github.com", "en": "https://github.com" @@ -410,10 +652,17 @@ "kr": "https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription", "en": "https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription" }, - "domains": ["github"] + "domains": [ + "github" + ], + "nameKr": "깃허브", + "nameEn": "GitHub" }, "cursor": { - "names": ["cursor", "커서"], + "names": [ + "cursor", + "커서" + ], "urls": { "kr": "https://cursor.com", "en": "https://cursor.com" @@ -422,10 +671,18 @@ "kr": null, "en": null }, - "domains": ["cursor"] + "domains": [ + "cursor" + ], + "nameKr": "커서", + "nameEn": "Cursor" }, "jetbrains": { - "names": ["jetbrains", "제트브레인스", "intellij"], + "names": [ + "jetbrains", + "제트브레인스", + "intellij" + ], "urls": { "kr": "https://www.jetbrains.com", "en": "https://www.jetbrains.com" @@ -434,10 +691,18 @@ "kr": "https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-", "en": "https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-" }, - "domains": ["jetbrains"] + "domains": [ + "jetbrains" + ], + "nameKr": "제트브레인스", + "nameEn": "JetBrains" }, "aws": { - "names": ["aws", "아마존 웹서비스", "amazon web services"], + "names": [ + "aws", + "아마존 웹서비스", + "amazon web services" + ], "urls": { "kr": "https://aws.amazon.com", "en": "https://aws.amazon.com" @@ -446,10 +711,19 @@ "kr": null, "en": null }, - "domains": ["aws.amazon", "aws"] + "domains": [ + "aws.amazon", + "aws" + ], + "nameKr": "아마존 웹서비스", + "nameEn": "aws" }, "azure": { - "names": ["azure", "애저", "microsoft azure"], + "names": [ + "azure", + "애저", + "microsoft azure" + ], "urls": { "kr": "https://azure.microsoft.com", "en": "https://azure.microsoft.com" @@ -458,10 +732,18 @@ "kr": null, "en": null }, - "domains": ["azure.microsoft"] + "domains": [ + "azure.microsoft" + ], + "nameKr": "애저", + "nameEn": "azure" }, "google_cloud": { - "names": ["google cloud", "구글 클라우드", "gcp"], + "names": [ + "google cloud", + "구글 클라우드", + "gcp" + ], "urls": { "kr": "https://cloud.google.com", "en": "https://cloud.google.com" @@ -470,7 +752,11 @@ "kr": null, "en": null }, - "domains": ["cloud.google"] + "domains": [ + "cloud.google" + ], + "nameKr": "구글 클라우드", + "nameEn": "google cloud" } } }, @@ -479,7 +765,12 @@ "nameEn": "Office/Collaboration", "services": { "microsoft_365": { - "names": ["microsoft 365", "마이크로소프트 365", "office 365", "오피스 365"], + "names": [ + "microsoft 365", + "마이크로소프트 365", + "office 365", + "오피스 365" + ], "urls": { "kr": "https://www.microsoft.com/microsoft-365", "en": "https://www.microsoft.com/microsoft-365" @@ -488,10 +779,17 @@ "kr": "https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b", "en": "https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b" }, - "domains": ["microsoft"] + "domains": [ + "microsoft" + ], + "nameKr": "마이크로소프트 365", + "nameEn": "Microsoft 365" }, "google_workspace": { - "names": ["google workspace", "구글 워크스페이스"], + "names": [ + "google workspace", + "구글 워크스페이스" + ], "urls": { "kr": "https://workspace.google.com", "en": "https://workspace.google.com" @@ -500,10 +798,17 @@ "kr": null, "en": "https://support.google.com/a/answer/1257646?hl=en" }, - "domains": ["workspace.google"] + "domains": [ + "workspace.google" + ], + "nameKr": "구글 워크스페이스", + "nameEn": "Google Workspace" }, "slack": { - "names": ["slack", "슬랙"], + "names": [ + "slack", + "슬랙" + ], "urls": { "kr": "https://slack.com", "en": "https://slack.com" @@ -512,10 +817,17 @@ "kr": "https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription", "en": "https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription" }, - "domains": ["slack"] + "domains": [ + "slack" + ], + "nameKr": "슬랙", + "nameEn": "Slack" }, "notion": { - "names": ["notion", "노션"], + "names": [ + "notion", + "노션" + ], "urls": { "kr": "https://www.notion.so", "en": "https://www.notion.so" @@ -524,10 +836,17 @@ "kr": "https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription", "en": "https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription" }, - "domains": ["notion"] + "domains": [ + "notion" + ], + "nameKr": "노션", + "nameEn": "Notion" }, "figma": { - "names": ["figma", "피그마"], + "names": [ + "figma", + "피그마" + ], "urls": { "kr": "https://www.figma.com", "en": "https://www.figma.com" @@ -536,10 +855,18 @@ "kr": null, "en": null }, - "domains": ["figma"] + "domains": [ + "figma" + ], + "nameKr": "피그마", + "nameEn": "Figma" }, "adobe_creative_cloud": { - "names": ["adobe creative cloud", "어도비 크리에이티브 클라우드", "adobe cc"], + "names": [ + "adobe creative cloud", + "어도비 크리에이티브 클라우드", + "adobe cc" + ], "urls": { "kr": "https://www.adobe.com/creativecloud.html", "en": "https://www.adobe.com/creativecloud.html" @@ -548,7 +875,11 @@ "kr": "https://helpx.adobe.com/manage-account/using/cancel-subscription.html", "en": "https://helpx.adobe.com/manage-account/using/cancel-subscription.html" }, - "domains": ["adobe"] + "domains": [ + "adobe" + ], + "nameKr": "어도비 크리에이티브 클라우드", + "nameEn": "Adobe Creative Cloud" } } }, @@ -557,7 +888,10 @@ "nameEn": "Lifestyle", "services": { "naver_plus": { - "names": ["네이버 플러스", "naver plus"], + "names": [ + "네이버 플러스", + "naver plus" + ], "urls": { "kr": "https://plus.naver.com", "en": null @@ -566,10 +900,17 @@ "kr": "https://help.naver.com/service/5638/contents/10041?osType=PC", "en": null }, - "domains": ["plus.naver"] + "domains": [ + "plus.naver" + ], + "nameKr": "네이버 플러스", + "nameEn": "naver plus" }, "kakao_subscribe": { - "names": ["카카오 구독", "kakao subscribe"], + "names": [ + "카카오 구독", + "kakao subscribe" + ], "urls": { "kr": "https://subscribe.kakao.com", "en": null @@ -578,10 +919,18 @@ "kr": null, "en": null }, - "domains": ["subscribe.kakao"] + "domains": [ + "subscribe.kakao" + ], + "nameKr": "카카오 구독", + "nameEn": "kakao subscribe" }, "coupang_wow": { - "names": ["쿠팡 와우", "coupang wow", "쿠팡와우"], + "names": [ + "쿠팡 와우", + "coupang wow", + "쿠팡와우" + ], "urls": { "kr": "https://www.coupang.com/np/coupangplus", "en": null @@ -590,10 +939,18 @@ "kr": "https://help.coupang.com/cc/ko/contents/faq/1000002013", "en": null }, - "domains": ["coupang"] + "domains": [ + "coupang" + ], + "nameKr": "쿠팡 와우", + "nameEn": "coupang wow" }, "kurly": { - "names": ["마켓컬리", "kurly", "컬리"], + "names": [ + "마켓컬리", + "kurly", + "컬리" + ], "urls": { "kr": "https://www.kurly.com", "en": null @@ -602,7 +959,11 @@ "kr": null, "en": null }, - "domains": ["kurly"] + "domains": [ + "kurly" + ], + "nameKr": "마켓컬리", + "nameEn": "kurly" } } }, @@ -611,7 +972,10 @@ "nameEn": "Shopping/E-commerce", "services": { "amazon_prime": { - "names": ["amazon prime", "아마존 프라임"], + "names": [ + "amazon prime", + "아마존 프라임" + ], "urls": { "kr": "https://www.amazon.co.kr/prime", "en": "https://www.amazon.com/prime" @@ -620,10 +984,17 @@ "kr": "https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M", "en": "https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M" }, - "domains": ["amazon"] + "domains": [ + "amazon" + ], + "nameKr": "아마존 프라임", + "nameEn": "Amazon Prime" }, "walmart_plus": { - "names": ["walmart+", "월마트플러스"], + "names": [ + "walmart+", + "월마트플러스" + ], "urls": { "kr": null, "en": "https://www.walmart.com/plus" @@ -632,7 +1003,11 @@ "kr": null, "en": "https://www.walmart.com/help/article/how-do-i-cancel-my-walmart-membership/2c1f2b2c9e6e4e3c9c8d9e5e" }, - "domains": ["walmart"] + "domains": [ + "walmart" + ], + "nameKr": "월마트플러스", + "nameEn": "walmart+" } } }, @@ -641,7 +1016,10 @@ "nameEn": "Gaming", "services": { "nintendo_switch_online": { - "names": ["nintendo switch online", "닌텐도 스위치 온라인"], + "names": [ + "nintendo switch online", + "닌텐도 스위치 온라인" + ], "urls": { "kr": "https://www.nintendo.com/switch/online-service", "en": "https://www.nintendo.com/switch/online-service" @@ -650,10 +1028,18 @@ "kr": "https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership", "en": "https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership" }, - "domains": ["nintendo"] + "domains": [ + "nintendo" + ], + "nameKr": "닌텐도 스위치 온라인", + "nameEn": "nintendo switch online" }, "playstation_plus": { - "names": ["playstation plus", "플레이스테이션 플러스", "ps plus"], + "names": [ + "playstation plus", + "플레이스테이션 플러스", + "ps plus" + ], "urls": { "kr": "https://www.playstation.com/ps-plus", "en": "https://www.playstation.com/ps-plus" @@ -662,10 +1048,17 @@ "kr": "https://www.playstation.com/support/subscriptions/cancel-playstation-plus/", "en": "https://www.playstation.com/support/subscriptions/cancel-playstation-plus/" }, - "domains": ["playstation"] + "domains": [ + "playstation" + ], + "nameKr": "플레이스테이션 플러스", + "nameEn": "playstation plus" }, "xbox_game_pass": { - "names": ["xbox game pass", "엑스박스 게임 패스"], + "names": [ + "xbox game pass", + "엑스박스 게임 패스" + ], "urls": { "kr": "https://www.xbox.com/xbox-game-pass", "en": "https://www.xbox.com/xbox-game-pass" @@ -674,10 +1067,17 @@ "kr": "https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel", "en": "https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel" }, - "domains": ["xbox"] + "domains": [ + "xbox" + ], + "nameKr": "엑스박스 게임 패스", + "nameEn": "xbox game pass" }, "steam": { - "names": ["steam", "스팀"], + "names": [ + "steam", + "스팀" + ], "urls": { "kr": "https://store.steampowered.com", "en": "https://store.steampowered.com" @@ -686,7 +1086,12 @@ "kr": null, "en": null }, - "domains": ["steampowered", "steam"] + "domains": [ + "steampowered", + "steam" + ], + "nameKr": "스팀", + "nameEn": "steam" } } }, @@ -695,7 +1100,10 @@ "nameEn": "Telecom/Internet/TV", "services": { "skt": { - "names": ["skt", "sk텔레콤"], + "names": [ + "skt", + "sk텔레콤" + ], "urls": { "kr": "https://www.sktelecom.com", "en": "https://www.sktelecom.com" @@ -704,10 +1112,16 @@ "kr": "https://www.sktelecom.com/support/cancel.do", "en": null }, - "domains": ["sktelecom"] + "domains": [ + "sktelecom" + ], + "nameKr": "sk텔레콤", + "nameEn": "skt" }, "kt": { - "names": ["kt"], + "names": [ + "kt" + ], "urls": { "kr": "https://www.kt.com", "en": "https://www.kt.com" @@ -716,10 +1130,18 @@ "kr": null, "en": null }, - "domains": ["kt"] + "domains": [ + "kt" + ], + "nameKr": "kt", + "nameEn": "kt" }, "lguplus": { - "names": ["lgu+", "lg유플러스", "lg u+"], + "names": [ + "lgu+", + "lg유플러스", + "lg u+" + ], "urls": { "kr": "https://www.lguplus.com", "en": "https://www.lguplus.com" @@ -728,7 +1150,11 @@ "kr": "https://www.lguplus.com/support/faq/faqDetail?faqId=FAQ00000000000002720", "en": null }, - "domains": ["lguplus"] + "domains": [ + "lguplus" + ], + "nameKr": "lg유플러스", + "nameEn": "lgu+" } } } diff --git a/assets/data/text.json b/assets/data/text.json new file mode 100644 index 0000000..07ee755 --- /dev/null +++ b/assets/data/text.json @@ -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": "请输入有效的金额" + } +} \ No newline at end of file diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart index 49e05a1..5f925d5 100644 --- a/lib/controllers/add_subscription_controller.dart +++ b/lib/controllers/add_subscription_controller.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; +import '../models/subscription_model.dart'; import '../providers/subscription_provider.dart'; import '../providers/category_provider.dart'; import '../services/sms_service.dart'; import '../services/subscription_url_matcher.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; +import '../l10n/app_localizations.dart'; /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller class AddSubscriptionController { @@ -23,7 +25,7 @@ class AddSubscriptionController { final eventPriceController = TextEditingController(); // Form State - String billingCycle = '월간'; + String billingCycle = 'monthly'; String currency = 'KRW'; DateTime? nextBillingDate; bool isLoading = false; @@ -172,7 +174,7 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showSuccess( context: context, - message: '${serviceInfo.serviceName} 서비스가 자동으로 인식되었습니다.', + message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName), ); } } @@ -215,7 +217,7 @@ class AddSubscriptionController { serviceName.contains('플로') || serviceName.contains('벅스')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '음악', + (cat) => cat.name == 'music', orElse: () => categories.first, ); } @@ -284,7 +286,7 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showError( context: context, - message: 'SMS 권한이 필요합니다.', + message: AppLocalizations.of(context).smsPermissionRequired, ); } return; @@ -296,7 +298,7 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showWarning( context: context, - message: '구독 관련 SMS를 찾을 수 없습니다.', + message: AppLocalizations.of(context).noSubscriptionSmsFound, ); } return; @@ -394,7 +396,7 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showError( context: context, - message: 'SMS 스캔 중 오류 발생: $e', + message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()), ); } } finally { @@ -450,7 +452,7 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showError( context: context, - message: '저장 중 오류가 발생했습니다: $e', + message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()), ); } } diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index bf05c79..0036f79 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -5,11 +5,13 @@ import '../models/subscription_model.dart'; import '../models/category_model.dart'; import '../providers/subscription_provider.dart'; import '../providers/category_provider.dart'; +import '../providers/locale_provider.dart'; import '../services/subscription_url_matcher.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:intl/intl.dart'; import '../widgets/dialogs/delete_confirmation_dialog.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; +import '../l10n/app_localizations.dart'; /// DetailScreen의 비즈니스 로직을 관리하는 Controller class DetailScreenController extends ChangeNotifier { @@ -22,6 +24,10 @@ class DetailScreenController extends ChangeNotifier { late TextEditingController websiteUrlController; late TextEditingController eventPriceController; + // Display Names + String? _displayName; + String? get displayName => _displayName; + // Form State final GlobalKey formKey = GlobalKey(); late String _billingCycle; @@ -197,6 +203,9 @@ class DetailScreenController extends ChangeNotifier { // 애니메이션 시작 animationController!.forward(); + // 로케일에 맞는 서비스명 로드 + _loadDisplayName(); + // 서비스명 변경 감지 리스너 serviceNameController.addListener(onServiceNameChanged); @@ -206,6 +215,20 @@ class DetailScreenController extends ChangeNotifier { }); } + /// 로케일에 맞는 서비스명 로드 + Future _loadDisplayName() async { + final localeProvider = context.read(); + final locale = localeProvider.locale.languageCode; + + final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( + serviceName: subscription.serviceName, + locale: locale, + ); + + _displayName = displayName; + notifyListeners(); + } + /// 리소스 정리 @override void dispose() { @@ -282,7 +305,7 @@ class DetailScreenController extends ChangeNotifier { serviceName.contains('플로') || serviceName.contains('벅스')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '음악 서비스', + (cat) => cat.name == 'music', orElse: () => categories.first, ); } @@ -295,7 +318,7 @@ class DetailScreenController extends ChangeNotifier { serviceName.contains('icloud') || serviceName.contains('adobe')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '오피스/협업 툴', + (cat) => cat.name == 'collaborationOffice', orElse: () => categories.first, ); } @@ -306,7 +329,7 @@ class DetailScreenController extends ChangeNotifier { serviceName.contains('copilot') || serviceName.contains('midjourney')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == 'AI 서비스', + (cat) => cat.name == 'aiService', orElse: () => categories.first, ); } @@ -317,7 +340,7 @@ class DetailScreenController extends ChangeNotifier { serviceName.contains('패스트캠퍼스') || serviceName.contains('클래스101')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '프로그래밍/개발', + (cat) => cat.name == 'programming', orElse: () => categories.first, ); } @@ -328,7 +351,7 @@ class DetailScreenController extends ChangeNotifier { serviceName.contains('네이버') || serviceName.contains('11번가')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '기타 서비스', + (cat) => cat.name == 'other', orElse: () => categories.first, ); } @@ -344,7 +367,7 @@ class DetailScreenController extends ChangeNotifier { if (formKey.currentState != null && !formKey.currentState!.validate()) { AppSnackBar.showError( context: context, - message: '필수 항목을 모두 입력해주세요', + message: AppLocalizations.of(context).requiredFieldsError, ); return; } @@ -368,6 +391,10 @@ class DetailScreenController extends ChangeNotifier { monthlyCost = subscription.monthlyCost; } + debugPrint('[DetailScreenController] 구독 업데이트 시작: ' + '${subscription.serviceName} → ${serviceNameController.text}, ' + '금액: ${subscription.monthlyCost} → $monthlyCost ${_currency}'); + subscription.serviceName = serviceNameController.text; subscription.monthlyCost = monthlyCost; subscription.websiteUrl = websiteUrl; @@ -393,13 +420,17 @@ class DetailScreenController extends ChangeNotifier { subscription.eventPrice = null; } + debugPrint('[DetailScreenController] 업데이트 정보: ' + '현재가격=${subscription.currentPrice}, ' + '이벤트활성=${subscription.isEventActive}'); + // 구독 업데이트 await provider.updateSubscription(subscription); if (context.mounted) { AppSnackBar.showSuccess( context: context, - message: '구독 정보가 업데이트되었습니다.', + message: AppLocalizations.of(context).subscriptionUpdated, ); // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 @@ -413,10 +444,18 @@ class DetailScreenController extends ChangeNotifier { /// 구독 삭제 Future deleteSubscription() async { if (context.mounted) { + // 로케일에 맞는 서비스명 가져오기 + final localeProvider = Provider.of(context, listen: false); + final locale = localeProvider.locale.languageCode; + final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( + serviceName: subscription.serviceName, + locale: locale, + ); + // 삭제 확인 다이얼로그 표시 final shouldDelete = await DeleteConfirmationDialog.show( context: context, - serviceName: subscription.serviceName, + serviceName: displayName, ); if (!shouldDelete) return; @@ -429,7 +468,7 @@ class DetailScreenController extends ChangeNotifier { if (context.mounted) { AppSnackBar.showError( context: context, - message: '구독이 삭제되었습니다.', + message: AppLocalizations.of(context).subscriptionDeleted(displayName), icon: Icons.delete_forever_rounded, ); Navigator.of(context).pop(); @@ -459,7 +498,7 @@ class DetailScreenController extends ChangeNotifier { if (context.mounted) { AppSnackBar.showInfo( context: context, - message: '공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.', + message: AppLocalizations.of(context).officialCancelPageNotFound, ); } } @@ -470,7 +509,7 @@ class DetailScreenController extends ChangeNotifier { if (context.mounted) { AppSnackBar.showError( context: context, - message: '웹사이트를 열 수 없습니다.', + message: AppLocalizations.of(context).cannotOpenWebsite, ); } } @@ -487,7 +526,7 @@ class DetailScreenController extends ChangeNotifier { if (context.mounted) { AppSnackBar.showError( context: context, - message: '웹사이트를 열 수 없습니다.', + message: AppLocalizations.of(context).cannotOpenWebsite, ); } } @@ -495,7 +534,7 @@ class DetailScreenController extends ChangeNotifier { if (context.mounted) { AppSnackBar.showWarning( context: context, - message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.', + message: AppLocalizations.of(context).noWebsiteInfo, ); } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d4cd3b8..44b5bd8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; class AppLocalizations { final Locale locale; + late Map _localizedStrings; AppLocalizations(this.locale); @@ -9,126 +12,467 @@ class AppLocalizations { return Localizations.of(context, AppLocalizations)!; } - static const _localizedValues = >{ - 'en': { - 'appTitle': 'SubManager', - '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', - }, - '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': '앱 잠금', - }, - }; + // JSON 파일에서 번역 데이터 로드 + Future load() async { + String jsonString = + await rootBundle.loadString('assets/data/text.json'); + Map jsonMap = json.decode(jsonString); + _localizedStrings = jsonMap[locale.languageCode]; + } - String get appTitle => _localizedValues[locale.languageCode]!['appTitle']!; + String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager'; + String get appSubtitle => _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily'; String get subscriptionManagement => - _localizedValues[locale.languageCode]!['subscriptionManagement']!; + _localizedStrings['subscriptionManagement'] ?? 'Subscription Management'; String get addSubscription => - _localizedValues[locale.languageCode]!['addSubscription']!; + _localizedStrings['addSubscription'] ?? 'Add Subscription'; String get subscriptionName => - _localizedValues[locale.languageCode]!['subscriptionName']!; + _localizedStrings['subscriptionName'] ?? 'Service Name'; String get monthlyCost => - _localizedValues[locale.languageCode]!['monthlyCost']!; + _localizedStrings['monthlyCost'] ?? 'Monthly Cost'; String get billingCycle => - _localizedValues[locale.languageCode]!['billingCycle']!; + _localizedStrings['billingCycle'] ?? 'Billing Cycle'; String get nextBillingDate => - _localizedValues[locale.languageCode]!['nextBillingDate']!; - String get save => _localizedValues[locale.languageCode]!['save']!; - String get cancel => _localizedValues[locale.languageCode]!['cancel']!; - String get delete => _localizedValues[locale.languageCode]!['delete']!; - String get edit => _localizedValues[locale.languageCode]!['edit']!; + _localizedStrings['nextBillingDate'] ?? 'Next Billing Date'; + String get save => _localizedStrings['save'] ?? 'Save'; + String get cancel => _localizedStrings['cancel'] ?? 'Cancel'; + String get delete => _localizedStrings['delete'] ?? 'Delete'; + String get edit => _localizedStrings['edit'] ?? 'Edit'; String get totalSubscriptions => - _localizedValues[locale.languageCode]!['totalSubscriptions']!; + _localizedStrings['totalSubscriptions'] ?? 'Total Subscriptions'; String get totalMonthlyExpense => - _localizedValues[locale.languageCode]!['totalMonthlyExpense']!; + _localizedStrings['totalMonthlyExpense'] ?? 'Total Monthly Expense'; String get noSubscriptions => - _localizedValues[locale.languageCode]!['noSubscriptions']!; + _localizedStrings['noSubscriptions'] ?? 'No subscriptions registered'; String get addSubscriptionNow => - _localizedValues[locale.languageCode]!['addSubscriptionNow']!; + _localizedStrings['addSubscriptionNow'] ?? 'Add Subscription Now'; String get paymentReminder => - _localizedValues[locale.languageCode]!['paymentReminder']!; + _localizedStrings['paymentReminder'] ?? 'Payment Reminder'; String get expirationReminder => - _localizedValues[locale.languageCode]!['expirationReminder']!; - String get daysLeft => _localizedValues[locale.languageCode]!['daysLeft']!; + _localizedStrings['expirationReminder'] ?? 'Expiration Reminder'; + String get daysLeft => _localizedStrings['daysLeft'] ?? 'days left'; String get categoryManagement => - _localizedValues[locale.languageCode]!['categoryManagement']!; + _localizedStrings['categoryManagement'] ?? 'Category Management'; String get categoryName => - _localizedValues[locale.languageCode]!['categoryName']!; + _localizedStrings['categoryName'] ?? 'Category Name'; String get selectColor => - _localizedValues[locale.languageCode]!['selectColor']!; + _localizedStrings['selectColor'] ?? 'Select Color'; String get selectIcon => - _localizedValues[locale.languageCode]!['selectIcon']!; + _localizedStrings['selectIcon'] ?? 'Select Icon'; String get addCategory => - _localizedValues[locale.languageCode]!['addCategory']!; - String get settings => _localizedValues[locale.languageCode]!['settings']!; - String get darkMode => _localizedValues[locale.languageCode]!['darkMode']!; - String get language => _localizedValues[locale.languageCode]!['language']!; + _localizedStrings['addCategory'] ?? 'Add Category'; + String get settings => _localizedStrings['settings'] ?? 'Settings'; + String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; + String get language => _localizedStrings['language'] ?? 'Language'; String get notifications => - _localizedValues[locale.languageCode]!['notifications']!; - String get appLock => _localizedValues[locale.languageCode]!['appLock']!; + _localizedStrings['notifications'] ?? 'Notifications'; + 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 { const AppLocalizationsDelegate(); @override - bool isSupported(Locale locale) => ['en', 'ko'].contains(locale.languageCode); + bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode); @override Future load(Locale locale) async { - return AppLocalizations(locale); + final localizations = AppLocalizations(locale); + await localizations.load(); + return localizations; } @override diff --git a/lib/main.dart b/lib/main.dart index b2ea7b0..b8848ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,7 +35,7 @@ Future main() async { // 성능 최적화 설정 MemoryManager.optimizeImageCache(); MemoryManager().startAutoCleanup(); - + // 앱 시작 시 이미지 캐시 관리 try { // 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비 @@ -118,9 +118,10 @@ class SubManagerApp extends StatelessWidget { builder: (context, localeProvider, themeProvider, child) { // 시스템 UI 오버레이 스타일 적용 AdaptiveTheme.applySystemUIOverlay(context); - + return MaterialApp( - title: 'SubManager', + key: ValueKey(localeProvider.locale), + title: 'Digital Rent Manager', debugShowCheckedModeBanner: false, theme: themeProvider.getTheme(context), locale: localeProvider.locale, @@ -133,6 +134,8 @@ class SubManagerApp extends StatelessWidget { supportedLocales: const [ Locale('en'), Locale('ko'), + Locale('ja'), + Locale('zh'), ], navigatorKey: navigatorKey, navigatorObservers: [AppNavigationObserver()], @@ -144,10 +147,11 @@ class SubManagerApp extends StatelessWidget { if (kDebugMode) { PerformanceOptimizer().startFrameMonitoring(); } - + return MediaQuery( 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, ), child: child!, diff --git a/lib/models/subscription_model.dart b/lib/models/subscription_model.dart index 5beca29..594c7ce 100644 --- a/lib/models/subscription_model.dart +++ b/lib/models/subscription_model.dart @@ -14,7 +14,7 @@ class SubscriptionModel extends HiveObject { double monthlyCost; @HiveField(3) - String billingCycle; // '월간', '연간', '주간' 등 + String billingCycle; // 'monthly', 'yearly', 'weekly' - 영어 키값 사용 @HiveField(4) DateTime nextBillingDate; @@ -98,6 +98,32 @@ class SubscriptionModel extends HiveObject { // 원래 가격 (이벤트와 관계없이 항상 정상 가격) 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 생성을 위한 명령어 diff --git a/lib/providers/category_provider.dart b/lib/providers/category_provider.dart index 446f7e1..585eadf 100644 --- a/lib/providers/category_provider.dart +++ b/lib/providers/category_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import '../models/category_model.dart'; import 'package:uuid/uuid.dart'; +import '../l10n/app_localizations.dart'; class CategoryProvider extends ChangeNotifier { List _categories = []; @@ -9,16 +10,16 @@ class CategoryProvider extends ChangeNotifier { // 카테고리 표시 순서 정의 static const List _categoryOrder = [ - '음악', - 'OTT(동영상)', - '저장/클라우드', - '통신 · 인터넷 · TV', - '생활/라이프스타일', - '쇼핑/이커머스', - '프로그래밍', - '협업/오피스', - 'AI 서비스', - '기타', + 'music', + 'ottVideo', + 'storageCloud', + 'telecomInternetTv', + 'lifestyle', + 'shoppingEcommerce', + 'programming', + 'collaborationOffice', + 'aiService', + 'other', ]; List get categories { @@ -53,16 +54,16 @@ class CategoryProvider extends ChangeNotifier { // 기본 카테고리 초기화 Future _initDefaultCategories() async { final defaultCategories = [ - {'name': '음악', 'color': '#E91E63', 'icon': 'music_note'}, - {'name': 'OTT(동영상)', 'color': '#9C27B0', 'icon': 'movie_filter'}, - {'name': '저장/클라우드', 'color': '#2196F3', 'icon': 'cloud'}, - {'name': '통신 · 인터넷 · TV', 'color': '#00BCD4', 'icon': 'wifi'}, - {'name': '생활/라이프스타일', 'color': '#4CAF50', 'icon': 'home'}, - {'name': '쇼핑/이커머스', 'color': '#FF9800', 'icon': 'shopping_cart'}, - {'name': '프로그래밍', 'color': '#795548', 'icon': 'code'}, - {'name': '협업/오피스', 'color': '#607D8B', 'icon': 'business_center'}, - {'name': 'AI 서비스', 'color': '#673AB7', 'icon': 'smart_toy'}, - {'name': '기타', 'color': '#9E9E9E', 'icon': 'category'}, + {'name': 'music', 'color': '#E91E63', 'icon': 'music_note'}, + {'name': 'ottVideo', 'color': '#9C27B0', 'icon': 'movie_filter'}, + {'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'}, + {'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'}, + {'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'}, + {'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'}, + {'name': 'programming', 'color': '#795548', 'icon': 'code'}, + {'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'}, + {'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'}, + {'name': 'other', 'color': '#9E9E9E', 'icon': 'category'}, ]; for (final category in defaultCategories) { @@ -116,4 +117,57 @@ class CategoryProvider extends ChangeNotifier { 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; + } + } + } } diff --git a/lib/providers/locale_provider.dart b/lib/providers/locale_provider.dart index 73f37a7..36dcb49 100644 --- a/lib/providers/locale_provider.dart +++ b/lib/providers/locale_provider.dart @@ -1,22 +1,48 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import 'dart:ui' as ui; class LocaleProvider extends ChangeNotifier { late Box _localeBox; Locale _locale = const Locale('ko'); + + static const List supportedLanguages = ['en', 'ko', 'ja', 'zh']; Locale get locale => _locale; Future init() async { _localeBox = await Hive.openBox('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(); } Future setLocale(String languageCode) async { - _locale = Locale(languageCode); - await _localeBox.put('locale', languageCode); - notifyListeners(); + if (_locale.languageCode != languageCode) { + _locale = Locale(languageCode); + await _localeBox.put('locale', languageCode); + notifyListeners(); + } } } diff --git a/lib/providers/navigation_provider.dart b/lib/providers/navigation_provider.dart index 14c2dfb..584b9fe 100644 --- a/lib/providers/navigation_provider.dart +++ b/lib/providers/navigation_provider.dart @@ -4,7 +4,7 @@ class NavigationProvider extends ChangeNotifier { int _currentIndex = 0; final List _navigationHistory = [0]; String _currentRoute = '/'; - String _currentTitle = '홈'; + String _currentTitle = 'home'; int get currentIndex => _currentIndex; List get navigationHistory => List.unmodifiable(_navigationHistory); @@ -28,10 +28,10 @@ class NavigationProvider extends ChangeNotifier { }; static const Map indexToTitle = { - 0: '홈', - 1: '분석', - 3: 'SMS 스캔', - 4: '설정', + 0: 'home', + 1: 'analysis', + 3: 'smsScanLabel', + 4: 'settings', }; void updateCurrentIndex(int index, {bool addToHistory = true}) { @@ -39,7 +39,7 @@ class NavigationProvider extends ChangeNotifier { _currentIndex = index; _currentRoute = indexToRoute[index] ?? '/'; - _currentTitle = indexToTitle[index] ?? '홈'; + _currentTitle = indexToTitle[index] ?? 'home'; if (addToHistory && index >= 0) { _navigationHistory.add(index); @@ -57,17 +57,17 @@ class NavigationProvider extends ChangeNotifier { if (index >= 0) { _currentIndex = index; - _currentTitle = indexToTitle[index] ?? '홈'; + _currentTitle = indexToTitle[index] ?? 'home'; } else { switch (route) { case '/add-subscription': - _currentTitle = '구독 추가'; + _currentTitle = 'addSubscription'; break; case '/subscription-detail': - _currentTitle = '구독 상세'; + _currentTitle = 'subscriptionDetail'; break; default: - _currentTitle = '홈'; + _currentTitle = 'home'; } } @@ -89,7 +89,7 @@ class NavigationProvider extends ChangeNotifier { void reset() { _currentIndex = 0; _currentRoute = '/'; - _currentTitle = '홈'; + _currentTitle = 'home'; _navigationHistory.clear(); _navigationHistory.add(0); notifyListeners(); @@ -98,7 +98,7 @@ class NavigationProvider extends ChangeNotifier { void clearHistoryAndGoHome() { _currentIndex = 0; _currentRoute = '/'; - _currentTitle = '홈'; + _currentTitle = 'home'; _navigationHistory.clear(); _navigationHistory.add(0); notifyListeners(); diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 08acd3a..d712f19 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart'; import '../models/subscription_model.dart'; import '../services/notification_service.dart'; import '../services/exchange_rate_service.dart'; +import '../services/currency_util.dart'; import 'category_provider.dart'; class SubscriptionProvider extends ChangeNotifier { @@ -20,16 +21,23 @@ class SubscriptionProvider extends ChangeNotifier { final rate = exchangeRateService.cachedUsdToKrwRate ?? ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; - return _subscriptions.fold( + final total = _subscriptions.fold( 0.0, (sum, subscription) { final price = subscription.currentPrice; if (subscription.currency == 'USD') { + debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' + '\$${price} × ₩$rate = ₩${price * rate}'); return sum + (price * rate); } + debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); return sum + price; }, ); + + debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: ' + '${_subscriptions.length}개 구독, 총액 ₩$total'); + return total; } /// 월간 총 비용을 반환합니다. @@ -81,6 +89,11 @@ class SubscriptionProvider extends ChangeNotifier { try { _subscriptions = _subscriptionBox.values.toList() ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate)); + + debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: ' + '${_subscriptions.length}개 구독, ' + '총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}'); + notifyListeners(); } catch (e) { debugPrint('구독 목록 새로고침 중 오류 발생: $e'); @@ -138,9 +151,13 @@ class SubscriptionProvider extends ChangeNotifier { Future updateSubscription(SubscriptionModel subscription) async { try { - notifyListeners(); + debugPrint('[SubscriptionProvider] updateSubscription 호출됨: ' + '${subscription.serviceName}, ' + '금액: ${subscription.monthlyCost} ${subscription.currency}, ' + '현재가격: ${subscription.currentPrice} ${subscription.currency}'); await _subscriptionBox.put(subscription.id, subscription); + debugPrint('[SubscriptionProvider] Hive에 저장 완료'); // 이벤트 관련 알림 업데이트 if (subscription.isEventActive && subscription.eventEndDate != null) { @@ -154,6 +171,8 @@ class SubscriptionProvider extends ChangeNotifier { await refreshSubscriptions(); + debugPrint('[SubscriptionProvider] 구독 업데이트 완료, ' + '현재 총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}'); notifyListeners(); } catch (e) { debugPrint('구독 업데이트 중 오류 발생: $e'); @@ -230,17 +249,59 @@ class SubscriptionProvider extends ChangeNotifier { } } - /// 총 월간 지출을 계산합니다. - Future calculateTotalExpense() async { - // 이미 존재하는 totalMonthlyExpense getter를 사용 - return totalMonthlyExpense; + /// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) + Future calculateTotalExpense({String? locale}) async { + if (_subscriptions.isEmpty) return 0.0; + + // 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; + } + } + } + + return total; } - /// 최근 6개월의 월별 지출 데이터를 반환합니다. - Future>> getMonthlyExpenseData() async { + /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) + Future>> getMonthlyExpenseData({String? locale}) async { final now = DateTime.now(); final List> monthlyData = []; + // locale이 제공되지 않으면 현재 로케일 사용 + final targetCurrency = locale != null + ? CurrencyUtil.getDefaultCurrency(locale) + : 'KRW'; // 기본값 + // 최근 6개월 데이터 생성 for (int i = 5; i >= 0; i--) { 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)) && subscription.nextBillingDate.isAfter(month)) { // 해당 월의 비용 계산 (이벤트 가격 고려) + double cost; if (subscription.isEventActive && subscription.eventStartDate != null && subscription.eventEndDate != null && month.isAfter(subscription.eventStartDate!) && month.isBefore(subscription.eventEndDate!)) { - monthTotal += subscription.eventPrice ?? subscription.monthlyCost; + cost = subscription.eventPrice ?? subscription.monthlyCost; } 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('벡스')) { categoryId = categories.firstWhere( - (cat) => cat.name == '음악 서비스', + (cat) => cat.name == 'music', orElse: () => categories.first, ).id; } @@ -357,7 +442,7 @@ class SubscriptionProvider extends ChangeNotifier { serviceName.contains('midjourney') || serviceName.contains('copilot')) { categoryId = categories.firstWhere( - (cat) => cat.name == 'AI 서비스', + (cat) => cat.name == 'aiService', orElse: () => categories.first, ).id; } @@ -367,7 +452,7 @@ class SubscriptionProvider extends ChangeNotifier { serviceName.contains('webstorm') || serviceName.contains('jetbrains')) { categoryId = categories.firstWhere( - (cat) => cat.name == '프로그래밍/개발', + (cat) => cat.name == 'programming', orElse: () => categories.first, ).id; } @@ -380,14 +465,14 @@ class SubscriptionProvider extends ChangeNotifier { serviceName.contains('icloud') || serviceName.contains('아이클라우드')) { categoryId = categories.firstWhere( - (cat) => cat.name == '오피스/협업 툴', + (cat) => cat.name == 'collaborationOffice', orElse: () => categories.first, ).id; } // 기타 서비스 (기본값) else { categoryId = categories.firstWhere( - (cat) => cat.name == '기타 서비스', + (cat) => cat.name == 'other', orElse: () => categories.first, ).id; } diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index 004785d..9d2943a 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; +import '../providers/locale_provider.dart'; import '../widgets/native_ad_widget.dart'; import '../widgets/analysis/analysis_screen_spacer.dart'; import '../widgets/analysis/subscription_pie_chart_card.dart'; @@ -22,8 +23,8 @@ class _AnalysisScreenState extends State double _totalExpense = 0; List> _monthlyData = []; - int _touchedIndex = -1; bool _isLoading = true; + String _lastDataHash = ''; @override void initState() { @@ -36,6 +37,23 @@ class _AnalysisScreenState extends State _loadData(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Provider 변경 감지 + final provider = Provider.of(context); + final currentHash = _calculateDataHash(provider); + + debugPrint('[AnalysisScreen] didChangeDependencies: ' + '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); + + // 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드 + if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) { + debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작'); + _loadData(); + } + } + @override void dispose() { _animationController.dispose(); @@ -43,24 +61,50 @@ class _AnalysisScreenState extends State 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 _loadData() async { + debugPrint('[AnalysisScreen] _loadData 호출됨'); setState(() { _isLoading = true; }); final provider = Provider.of(context, listen: false); + final localeProvider = Provider.of(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(() { _isLoading = false; }); // 데이터 로드 완료 후 애니메이션 시작 + _animationController.reset(); _animationController.forward(); } @@ -85,74 +129,72 @@ class _AnalysisScreenState extends State @override Widget build(BuildContext context) { - return Consumer( - builder: (context, provider, child) { - final subscriptions = provider.subscriptions; + // Provider를 직접 사용하여 변경 감지 + final provider = Provider.of(context); + final subscriptions = provider.subscriptions; - if (_isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } - return CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: SizedBox( - height: kToolbarHeight + MediaQuery.of(context).padding.top, - ), - ), - - // 네이티브 광고 위젯 - SliverToBoxAdapter( - child: _buildAnimatedAd(), - ), + return CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: SizedBox( + height: kToolbarHeight + MediaQuery.of(context).padding.top, + ), + ), + + // 네이티브 광고 위젯 + SliverToBoxAdapter( + child: _buildAnimatedAd(), + ), - const AnalysisScreenSpacer(), + const AnalysisScreenSpacer(), - // 1. 구독 비율 파이 차트 - SubscriptionPieChartCard( - subscriptions: subscriptions, - animationController: _animationController, - touchedIndex: _touchedIndex, - onPieTouch: (index) => setState(() => _touchedIndex = index), - ), + // 1. 구독 비율 파이 차트 + SubscriptionPieChartCard( + subscriptions: subscriptions, + animationController: _animationController, + ), - const AnalysisScreenSpacer(), + const AnalysisScreenSpacer(), - // 2. 총 지출 요약 카드 - TotalExpenseSummaryCard( - subscriptions: subscriptions, - totalExpense: _totalExpense, - animationController: _animationController, - ), + // 2. 총 지출 요약 카드 + TotalExpenseSummaryCard( + key: ValueKey('total_expense_${_lastDataHash}'), + subscriptions: subscriptions, + totalExpense: _totalExpense, + animationController: _animationController, + ), - const AnalysisScreenSpacer(), + const AnalysisScreenSpacer(), - // 3. 월별 지출 차트 - MonthlyExpenseChartCard( - monthlyData: _monthlyData, - animationController: _animationController, - ), + // 3. 월별 지출 차트 + MonthlyExpenseChartCard( + key: ValueKey('monthly_expense_${_lastDataHash}'), + monthlyData: _monthlyData, + animationController: _animationController, + ), - const AnalysisScreenSpacer(), + const AnalysisScreenSpacer(), - // 4. 이벤트 분석 - EventAnalysisCard( - animationController: _animationController, - ), + // 4. 이벤트 분석 + EventAnalysisCard( + animationController: _animationController, + ), - // FloatingNavigationBar를 위한 충분한 하단 여백 - SliverToBoxAdapter( - child: SizedBox( - height: 120 + MediaQuery.of(context).padding.bottom, - ), - ), - ], - ); - }, + // FloatingNavigationBar를 위한 충분한 하단 여백 + SliverToBoxAdapter( + child: SizedBox( + height: 120 + MediaQuery.of(context).padding.bottom, + ), + ), + ], ); } } \ No newline at end of file diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index 1e00b22..c7c590d 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/category_provider.dart'; import '../theme/app_colors.dart'; +import '../l10n/app_localizations.dart'; class CategoryManagementScreen extends StatefulWidget { const CategoryManagementScreen({super.key}); @@ -89,15 +90,15 @@ class _CategoryManagementScreenState extends State { ), items: [ DropdownMenuItem( - value: '#1976D2', child: Text('파란색', style: TextStyle(color: AppColors.darkNavy))), + value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: '#4CAF50', child: Text('초록색', style: TextStyle(color: AppColors.darkNavy))), + value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: '#FF9800', child: Text('주황색', style: TextStyle(color: AppColors.darkNavy))), + value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: '#F44336', child: Text('빨간색', style: TextStyle(color: AppColors.darkNavy))), + value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))), 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) { setState(() { @@ -162,7 +163,7 @@ class _CategoryManagementScreenState extends State { int.parse(category.color.replaceAll('#', '0xFF'))), ), title: Text( - category.name, + provider.getLocalizedCategoryName(context, category.name), style: TextStyle( color: AppColors.darkNavy, ), diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 77fc21b..a430af2 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -8,6 +8,7 @@ import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_url_section.dart'; import '../widgets/detail/detail_action_buttons.dart'; import '../theme/app_colors.dart'; +import '../l10n/app_localizations.dart'; /// 구독 상세 정보를 표시하고 편집할 수 있는 화면 class DetailScreen extends StatefulWidget { @@ -100,7 +101,7 @@ class _DetailScreenState extends State ), const SizedBox(width: 8), Text( - '편집 모드', + AppLocalizations.of(context).editMode, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -109,7 +110,7 @@ class _DetailScreenState extends State ), const Spacer(), Text( - '변경사항은 저장 후 적용됩니다', + AppLocalizations.of(context).changesAppliedAfterSave, style: TextStyle( fontSize: 14, color: AppColors.darkNavy, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index b5bc393..56f19ae 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -13,6 +13,7 @@ import '../utils/animation_controller_helper.dart'; import '../widgets/floating_navigation_bar.dart'; import '../widgets/glassmorphic_scaffold.dart'; import '../widgets/home_content.dart'; +import '../l10n/app_localizations.dart'; class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -162,17 +163,17 @@ class _MainScreenState extends State if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Row( + content: Row( children: [ - Icon( + const Icon( Icons.check_circle, color: AppColors.pureWhite, size: 20, ), - SizedBox(width: 12), + const SizedBox(width: 12), Text( - '구독이 추가되었습니다', - style: TextStyle( + AppLocalizations.of(context).subscriptionAdded, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.pureWhite, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e6382ba..8c92407 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -5,12 +5,13 @@ import '../providers/notification_provider.dart'; import 'dart:io'; import '../services/notification_service.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../providers/theme_provider.dart'; import '../theme/adaptive_theme.dart'; import '../widgets/glassmorphism_card.dart'; import '../theme/app_colors.dart'; import '../widgets/native_ad_widget.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; +import '../l10n/app_localizations.dart'; +import '../providers/locale_provider.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -83,6 +84,99 @@ class SettingsScreen extends StatelessWidget { // 광고 위젯 추가 const NativeAdWidget(key: ValueKey('settings_ad')), const SizedBox(height: 16), + // 언어 설정 + GlassmorphismCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), + child: Consumer( + 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( + 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 숨김 // Card( // margin: const EdgeInsets.all(16), @@ -116,13 +210,16 @@ class SettingsScreen extends StatelessWidget { return Column( children: [ ListTile( - title: const Text( - '알림 권한', - style: TextStyle(color: AppColors.textPrimary), + title: Text( + AppLocalizations.of(context).notificationPermission, + style: + const TextStyle(color: AppColors.textPrimary), ), - subtitle: const Text( - '알림을 받으려면 권한이 필요합니다', - style: TextStyle(color: AppColors.textSecondary), + subtitle: Text( + AppLocalizations.of(context) + .notificationPermissionDesc, + style: + const TextStyle(color: AppColors.textSecondary), ), trailing: ElevatedButton( onPressed: () async { @@ -133,23 +230,28 @@ class SettingsScreen extends StatelessWidget { } else { AppSnackBar.showError( context: context, - message: '알림 권한이 거부되었습니다', + message: AppLocalizations.of(context) + .notificationPermissionDenied, ); } }, - child: const Text('권한 요청'), + child: Text( + AppLocalizations.of(context).requestPermission), ), ), const Divider(), // 결제 예정 알림 기본 스위치 SwitchListTile( - title: const Text( - '결제 예정 알림', - style: TextStyle(color: AppColors.textPrimary), + title: Text( + AppLocalizations.of(context).paymentNotification, + style: + const TextStyle(color: AppColors.textPrimary), ), - subtitle: const Text( - '결제 예정일 알림 받기', - style: TextStyle(color: AppColors.textSecondary), + subtitle: Text( + AppLocalizations.of(context) + .paymentNotificationDesc, + style: + const TextStyle(color: AppColors.textSecondary), ), value: provider.isPaymentEnabled, onChanged: (value) { @@ -181,8 +283,10 @@ class SettingsScreen extends StatelessWidget { CrossAxisAlignment.start, children: [ // 알림 시점 선택 (1일전, 2일전, 3일전) - const Text('알림 시점', - style: TextStyle( + Text( + AppLocalizations.of(context) + .notificationTiming, + style: const TextStyle( fontWeight: FontWeight.bold)), const SizedBox(height: 8), Padding( @@ -192,12 +296,24 @@ class SettingsScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildReminderDayRadio(context, - provider, 1, '1일 전'), - _buildReminderDayRadio(context, - provider, 2, '2일 전'), - _buildReminderDayRadio(context, - provider, 3, '3일 전'), + _buildReminderDayRadio( + context, + provider, + 1, + AppLocalizations.of(context) + .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 Text('알림 시간', - style: TextStyle( + Text( + AppLocalizations.of(context) + .notificationTime, + style: const TextStyle( fontWeight: FontWeight.bold)), const SizedBox(height: 12), InkWell( @@ -304,13 +422,21 @@ class SettingsScreen extends StatelessWidget { const EdgeInsets .symmetric( horizontal: 12), - title: - const Text('1일마다 반복 알림'), + title: Text( + AppLocalizations.of( + context) + .dailyReminder), subtitle: Text( provider.isDailyReminderEnabled - ? '결제일까지 매일 알림을 받습니다' - : '결제 ${provider.reminderDays}일 전에 알림을 받습니다', - style: TextStyle( + ? AppLocalizations.of( + context) + .dailyReminderEnabled + : AppLocalizations.of( + context) + .dailyReminderDisabledWithDays( + provider + .reminderDays), + style: const TextStyle( color: AppColors .textLight), ), @@ -355,13 +481,13 @@ class SettingsScreen extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.all(8), child: ListTile( - title: const Text( - '앱 정보', - style: TextStyle(color: AppColors.textPrimary), + title: Text( + AppLocalizations.of(context).appInfo, + style: const TextStyle(color: AppColors.textPrimary), ), - subtitle: const Text( - '버전 1.0.0', - style: TextStyle(color: AppColors.textSecondary), + subtitle: Text( + '${AppLocalizations.of(context).version} 1.0.0', + style: const TextStyle(color: AppColors.textSecondary), ), leading: const Icon( Icons.info, @@ -372,13 +498,14 @@ class SettingsScreen extends StatelessWidget { if (kIsWeb) { showAboutDialog( context: context, - applicationName: 'Digital Rent Manager', + applicationName: AppLocalizations.of(context).appTitle, applicationVersion: '1.0.0', applicationIcon: const FlutterLogo(size: 50), children: [ - const Text('디지털 월세 관리 앱'), + Text(AppLocalizations.of(context).appDescription), const SizedBox(height: 8), - const Text('개발자: Julian Sul'), + Text( + '${AppLocalizations.of(context).developer}: Julian Sul'), ], ); return; @@ -407,7 +534,8 @@ class SettingsScreen extends StatelessWidget { if (context.mounted) { AppSnackBar.showError( context: context, - message: '스토어를 열 수 없습니다', + message: + AppLocalizations.of(context).cannotOpenStore, ); } } @@ -415,13 +543,14 @@ class SettingsScreen extends StatelessWidget { // 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시 showAboutDialog( context: context, - applicationName: 'SubManager', + applicationName: AppLocalizations.of(context).appTitle, applicationVersion: '1.0.0', applicationIcon: const FlutterLogo(size: 50), children: [ - const Text('구독 관리 앱'), + Text(AppLocalizations.of(context).appDescription), 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; - } - } } diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index e75e5f8..a75da6a 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import '../services/sms_scanner.dart'; import '../providers/subscription_provider.dart'; import '../providers/navigation_provider.dart'; +import '../providers/locale_provider.dart'; import 'package:provider/provider.dart'; import '../models/subscription.dart'; import '../models/subscription_model.dart'; import '../services/subscription_url_matcher.dart'; +import '../services/currency_util.dart'; import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가 import '../widgets/glassmorphism_card.dart'; import '../widgets/themed_text.dart'; @@ -18,6 +20,7 @@ import '../providers/category_provider.dart'; import '../models/category_model.dart'; import '../widgets/common/form_fields/category_selector.dart'; import '../widgets/native_ad_widget.dart'; +import '../l10n/app_localizations.dart'; class SmsScanScreen extends StatefulWidget { const SmsScanScreen({super.key}); @@ -75,7 +78,7 @@ class _SmsScanScreenState extends State { if (scannedSubscriptionModels.isEmpty) { print('스캔된 구독이 없음'); setState(() { - _errorMessage = '구독 정보를 찾을 수 없습니다.'; + _errorMessage = AppLocalizations.of(context).subscriptionNotFound; _isLoading = false; }); return; @@ -98,7 +101,7 @@ class _SmsScanScreenState extends State { if (repeatSubscriptions.isEmpty) { print('반복 결제된 구독이 없음'); setState(() { - _errorMessage = '반복 결제된 구독 정보를 찾을 수 없습니다.'; + _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; _isLoading = false; }); return; @@ -131,7 +134,7 @@ class _SmsScanScreenState extends State { if (mounted) { AppSnackBar.showInfo( context: context, - message: '신규 구독 관련 SMS를 찾을 수 없습니다', + message: AppLocalizations.of(context).newSubscriptionNotFound, icon: Icons.search_off_rounded, ); } @@ -148,7 +151,7 @@ class _SmsScanScreenState extends State { print('SMS 스캔 중 오류 발생: $e'); if (mounted) { setState(() { - _errorMessage = 'SMS 스캔 중 오류가 발생했습니다: $e'; + _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); _isLoading = false; }); } @@ -389,7 +392,7 @@ class _SmsScanScreenState extends State { if (mounted) { AppSnackBar.showSuccess( context: context, - message: '${subscription.serviceName} 구독이 추가되었습니다.', + message: AppLocalizations.of(context).subscriptionAddedWithName(subscription.serviceName), ); } @@ -400,7 +403,7 @@ class _SmsScanScreenState extends State { if (mounted) { AppSnackBar.showError( context: context, - message: '구독 추가 중 오류가 발생했습니다: $e', + message: AppLocalizations.of(context).subscriptionAddErrorWithMessage(e.toString()), ); // 오류가 있어도 다음 구독으로 이동 @@ -416,7 +419,7 @@ class _SmsScanScreenState extends State { if (mounted) { AppSnackBar.showInfo( context: context, - message: '${subscription.serviceName} 구독을 건너뛰었습니다.', + message: AppLocalizations.of(context).subscriptionSkipped(subscription.serviceName), icon: Icons.skip_next_rounded, ); } @@ -447,7 +450,7 @@ class _SmsScanScreenState extends State { // 완료 메시지 표시 AppSnackBar.showSuccess( context: context, - message: '모든 구독이 처리되었습니다.', + message: AppLocalizations.of(context).allSubscriptionsProcessed, ); } @@ -482,7 +485,7 @@ class _SmsScanScreenState extends State { } 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 == '연간') { // 올해 또는 내년 같은 날짜 int day = date.day; @@ -503,14 +506,14 @@ class _SmsScanScreenState extends State { } final daysUntil = adjusted.difference(now).inDays; - return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)'; + return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil); } else { return '다음 결제일 확인 필요 (과거 날짜)'; } } else { // 미래 날짜인 경우 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 { // 결제 반복 횟수 텍스트 String _getRepeatCountText(int count) { - return '$count회 결제 감지됨'; + return AppLocalizations.of(context).repeatCountDetected(count); } // 카테고리 칩 빌드 @@ -532,7 +535,7 @@ class _SmsScanScreenState extends State { // 카테고리가 없으면 기타 카테고리 찾기 final defaultCategory = category ?? categoryProvider.categories.firstWhere( - (cat) => cat.name == '기타', + (cat) => cat.name == 'other', orElse: () => categoryProvider.categories.first, ); @@ -553,7 +556,7 @@ class _SmsScanScreenState extends State { ), const SizedBox(width: 6), ThemedText( - defaultCategory.name, + categoryProvider.getLocalizedCategoryName(context, defaultCategory.name), fontSize: 14, fontWeight: FontWeight.w500, forceDark: true, @@ -567,25 +570,25 @@ class _SmsScanScreenState extends State { // 카테고리 아이콘 반환 IconData _getCategoryIcon(CategoryModel category) { switch (category.name) { - case '음악': + case 'music': return Icons.music_note_rounded; - case 'OTT(동영상)': + case 'ottVideo': return Icons.movie_filter_rounded; - case '저장/클라우드': + case 'storageCloud': return Icons.cloud_outlined; - case '통신 · 인터넷 · TV': + case 'telecomInternetTv': return Icons.wifi_rounded; - case '생활/라이프스타일': + case 'lifestyle': return Icons.home_outlined; - case '쇼핑/이커머스': + case 'shoppingEcommerce': return Icons.shopping_cart_outlined; - case '프로그래밍': + case 'programming': return Icons.code_rounded; - case '협업/오피스': + case 'collaborationOffice': return Icons.business_center_outlined; - case 'AI 서비스': + case 'aiService': return Icons.smart_toy_outlined; - case '기타': + case 'other': default: return Icons.category_outlined; } @@ -595,7 +598,7 @@ class _SmsScanScreenState extends State { String _getDefaultCategoryId() { final categoryProvider = Provider.of(context, listen: false); final otherCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name == '기타', + (cat) => cat.name == 'other', orElse: () => categoryProvider.categories.first, // 만약 "기타"가 없으면 첫 번째 카테고리 ); print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); @@ -638,9 +641,9 @@ class _SmsScanScreenState extends State { valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), ), const SizedBox(height: 16), - const ThemedText('SMS 메시지를 스캔 중입니다...', forceDark: true), + ThemedText(AppLocalizations.of(context).scanningMessages, forceDark: true), 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 { textAlign: TextAlign.center, ), ), - const ThemedText( - '2회 이상 결제된 구독 서비스 찾기', + ThemedText( + AppLocalizations.of(context).findRepeatSubscriptions, fontSize: 20, fontWeight: FontWeight.bold, forceDark: true, ), const SizedBox(height: 16), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: ThemedText( - '문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.', + AppLocalizations.of(context).scanTextMessages, textAlign: TextAlign.center, opacity: 0.7, forceDark: true, @@ -686,7 +689,7 @@ class _SmsScanScreenState extends State { ), const SizedBox(height: 32), PrimaryButton( - text: '스캔 시작하기', + text: AppLocalizations.of(context).startScanning, icon: Icons.search_rounded, onPressed: _scanSms, width: 200, @@ -751,16 +754,16 @@ class _SmsScanScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ThemedText( - '다음 구독을 찾았습니다', + ThemedText( + AppLocalizations.of(context).foundSubscription, fontSize: 18, fontWeight: FontWeight.bold, forceDark: true, ), const SizedBox(height: 24), // 서비스명 - const ThemedText( - '서비스명', + ThemedText( + AppLocalizations.of(context).serviceName, fontWeight: FontWeight.w500, opacity: 0.7, forceDark: true, @@ -781,28 +784,28 @@ class _SmsScanScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ThemedText( - '월 비용', + ThemedText( + AppLocalizations.of(context).monthlyCost, fontWeight: FontWeight.w500, opacity: 0.7, forceDark: true, ), const SizedBox(height: 4), - ThemedText( - subscription.currency == 'USD' - ? NumberFormat.currency( - locale: 'en_US', - symbol: '\$', - decimalDigits: 2, - ).format(subscription.monthlyCost) - : NumberFormat.currency( - locale: 'ko_KR', - symbol: '₩', - decimalDigits: 0, - ).format(subscription.monthlyCost), - fontSize: 18, - fontWeight: FontWeight.bold, - forceDark: true, + // 언어별 통화 표시 + FutureBuilder( + future: CurrencyUtil.formatAmountWithLocale( + subscription.monthlyCost, + subscription.currency, + context.read().locale.languageCode, + ), + builder: (context, snapshot) { + return ThemedText( + snapshot.data ?? '-', + fontSize: 18, + fontWeight: FontWeight.bold, + forceDark: true, + ); + }, ), ], ), @@ -811,8 +814,8 @@ class _SmsScanScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ThemedText( - '결제 주기', + ThemedText( + AppLocalizations.of(context).billingCycle, fontWeight: FontWeight.w500, opacity: 0.7, forceDark: true, @@ -832,8 +835,8 @@ class _SmsScanScreenState extends State { const SizedBox(height: 16), // 다음 결제일 - const ThemedText( - '다음 결제일', + ThemedText( + AppLocalizations.of(context).nextBillingDateLabel, fontWeight: FontWeight.w500, opacity: 0.7, forceDark: true, @@ -848,8 +851,8 @@ class _SmsScanScreenState extends State { const SizedBox(height: 16), // 카테고리 선택 - const ThemedText( - '카테고리', + ThemedText( + AppLocalizations.of(context).category, fontWeight: FontWeight.w500, opacity: 0.7, forceDark: true, @@ -877,8 +880,8 @@ class _SmsScanScreenState extends State { // 웹사이트 URL 입력 필드 추가/수정 BaseTextField( controller: _websiteUrlController, - label: '웹사이트 URL (자동 추출됨)', - hintText: '웹사이트 URL을 수정하거나 비워두세요', + label: AppLocalizations.of(context).websiteUrlAuto, + hintText: AppLocalizations.of(context).websiteUrlHint, prefixIcon: Icon( Icons.language, color: AppColors.navyGray, @@ -895,7 +898,7 @@ class _SmsScanScreenState extends State { children: [ Expanded( child: SecondaryButton( - text: '건너뛰기', + text: AppLocalizations.of(context).skip, onPressed: _skipCurrentSubscription, height: 48, ), @@ -903,7 +906,7 @@ class _SmsScanScreenState extends State { const SizedBox(width: 16), Expanded( child: PrimaryButton( - text: '추가하기', + text: AppLocalizations.of(context).add, onPressed: _addCurrentSubscription, height: 48, ), diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 3be859b..16c8845 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; import '../routes/app_routes.dart'; +import '../l10n/app_localizations.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -323,7 +324,7 @@ class _SplashScreenState extends State ); }, child: Text( - 'Digital Rent Manager', + AppLocalizations.of(context).appTitle, style: TextStyle( fontSize: 36, fontWeight: FontWeight.bold, @@ -350,7 +351,7 @@ class _SplashScreenState extends State ); }, child: Text( - '구독 서비스 관리를 더 쉽게', + AppLocalizations.of(context).appSubtitle, style: TextStyle( fontSize: 16, color: AppColors.primaryColor diff --git a/lib/services/currency_util.dart b/lib/services/currency_util.dart index 318aa57..f761504 100644 --- a/lib/services/currency_util.dart +++ b/lib/services/currency_util.dart @@ -6,67 +6,166 @@ import 'exchange_rate_service.dart'; class CurrencyUtil { static final ExchangeRateService _exchangeRateService = ExchangeRateService(); - /// 구독 목록의 총 월 비용을 계산 (원화로 환산, 이벤트 가격 반영) - static Future calculateTotalMonthlyExpense( - List subscriptions) async { + /// 언어에 따른 기본 통화 반환 + static String getDefaultCurrency(String locale) { + 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 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 calculateTotalMonthlyExpenseInDefaultCurrency( + List subscriptions, + String locale, + ) async { + final defaultCurrency = getDefaultCurrency(locale); double total = 0.0; for (var subscription in subscriptions) { - // 이벤트 가격이 있으면 currentPrice 사용 final price = subscription.currentPrice; - if (subscription.currency == 'USD') { - // USD인 경우 KRW로 변환 - final krwAmount = await _exchangeRateService - .convertUsdToKrw(price); - if (krwAmount != null) { - total += krwAmount; - } - } else { - // KRW인 경우 그대로 합산 + if (subscription.currency == defaultCurrency) { + // 기본 통화면 그대로 합산 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; } - /// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - static Future formatSubscriptionAmount( - SubscriptionModel subscription) async { - // 이벤트 가격이 있으면 currentPrice 사용 - 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 Future calculateTotalMonthlyExpense( + List subscriptions) async { + return calculateTotalMonthlyExpenseInDefaultCurrency(subscriptions, 'ko'); } - /// 총액을 원화로 표시 + /// 구독의 월 비용을 표시 형식에 맞게 변환 (언어별 통화) + static Future formatSubscriptionAmountWithLocale( + SubscriptionModel subscription, String locale) async { + final price = subscription.currentPrice; + return formatAmountWithLocale(price, subscription.currency, locale); + } + + /// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지 + static Future formatSubscriptionAmount( + SubscriptionModel subscription) async { + return formatSubscriptionAmountWithLocale(subscription, 'ko'); + } + + /// 총액을 언어별 기본 통화로 표시 + static String formatTotalAmountWithLocale(double amount, String locale) { + final defaultCurrency = getDefaultCurrency(locale); + return _formatSingleCurrency(amount, defaultCurrency); + } + + /// 총액을 원화로 표시 - 기존 호환성 유지 static String formatTotalAmount(double amount) { - return NumberFormat.currency( - locale: 'ko_KR', - symbol: '₩', - decimalDigits: 0, - ).format(amount); + return formatTotalAmountWithLocale(amount, 'ko'); } /// 환율 정보 텍스트 가져오기 @@ -74,25 +173,34 @@ class CurrencyUtil { return _exchangeRateService.getFormattedExchangeRateInfo(); } - /// 이벤트로 인한 총 절약액 계산 (원화로 환산) - static Future calculateTotalEventSavings( - List subscriptions) async { + /// 언어별 환율 정보 텍스트 가져오기 + static Future getExchangeRateInfoForLocale(String locale) { + return _exchangeRateService.getFormattedExchangeRateInfoForLocale(locale); + } + + /// 이벤트로 인한 총 절약액 계산 (언어별 기본 통화로) + static Future calculateTotalEventSavingsInDefaultCurrency( + List subscriptions, String locale) async { + final defaultCurrency = getDefaultCurrency(locale); double total = 0.0; for (var subscription in subscriptions) { if (subscription.isCurrentlyInEvent) { final savings = subscription.eventSavings; - if (subscription.currency == 'USD') { - // USD인 경우 KRW로 변환 - final krwAmount = await _exchangeRateService - .convertUsdToKrw(savings); - if (krwAmount != null) { - total += krwAmount; - } - } else { - // KRW인 경우 그대로 합산 + if (subscription.currency == defaultCurrency) { 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; } - /// 이벤트 절약액을 표시 형식에 맞게 변환 - static Future formatEventSavings( - SubscriptionModel subscription) async { + /// 이벤트로 인한 총 절약액 계산 (원화로 환산) - 기존 호환성 유지 + static Future calculateTotalEventSavings( + List subscriptions) async { + return calculateTotalEventSavingsInDefaultCurrency(subscriptions, 'ko'); + } + + /// 이벤트 절약액을 표시 형식에 맞게 변환 (언어별) + static Future formatEventSavingsWithLocale( + SubscriptionModel subscription, String locale) async { if (!subscription.isCurrentlyInEvent) { return ''; } final savings = subscription.eventSavings; - - 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); - } + return formatAmountWithLocale(savings, subscription.currency, locale); } - /// 금액과 통화를 받아 포맷팅하여 반환 + /// 이벤트 절약액을 표시 형식에 맞게 변환 - 기존 호환성 유지 + static Future formatEventSavings( + SubscriptionModel subscription) async { + return formatEventSavingsWithLocale(subscription, 'ko'); + } + + /// 금액과 통화를 받아 포맷팅하여 반환 (언어별) + static Future formatAmountWithCurrencyAndLocale( + double amount, String currency, String locale) async { + return formatAmountWithLocale(amount, currency, locale); + } + + /// 금액과 통화를 받아 포맷팅하여 반환 - 기존 호환성 유지 static Future formatAmount(double amount, String currency) async { - if (currency == 'USD') { - // 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); - } + return formatAmountWithCurrencyAndLocale(amount, currency, 'ko'); } -} +} \ No newline at end of file diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index c59651e..e0c6073 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -17,6 +17,8 @@ class ExchangeRateService { // 캐싱된 환율 정보 double? _usdToKrwRate; + double? _usdToJpyRate; + double? _usdToCnyRate; DateTime? _lastUpdated; // 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_JPY_RATE = 150.0; + static const double DEFAULT_USD_TO_CNY_RATE = 7.2; // 캐싱된 환율 반환 (동기적) double? get cachedUsdToKrwRate => _usdToKrwRate; - /// 현재 USD to KRW 환율 정보를 가져옵니다. - /// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 반환합니다. - Future getUsdToKrwRate() async { - // 캐싱된 데이터 있고 6시간 이내면 캐싱된 데이터 반환 - if (_usdToKrwRate != null && _lastUpdated != null) { + /// 모든 환율 정보를 한 번에 가져옵니다. + /// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 사용합니다. + Future _fetchAllRatesIfNeeded() async { + // 캐싱된 데이터 있고 6시간 이내면 스킵 + if (_lastUpdated != null) { final difference = DateTime.now().difference(_lastUpdated!); if (difference.inHours < 6) { - return _usdToKrwRate; + return; } } @@ -45,19 +49,22 @@ class ExchangeRateService { if (response.statusCode == 200) { 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(); - return _usdToKrwRate; - } else { - // 실패 시 캐싱된 값이라도 반환 - return _usdToKrwRate; } } catch (e) { - // 오류 발생 시 캐싱된 값이라도 반환 - return _usdToKrwRate; + // 오류 발생 시 기본값 사용 } } + /// 현재 USD to KRW 환율 정보를 가져옵니다. + Future getUsdToKrwRate() async { + await _fetchAllRatesIfNeeded(); + return _usdToKrwRate; + } + /// USD 금액을 KRW로 변환합니다. Future convertUsdToKrw(double usdAmount) async { final rate = await getUsdToKrwRate(); @@ -67,6 +74,48 @@ class ExchangeRateService { return null; } + /// USD 금액을 지정된 통화로 변환합니다. + Future 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 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 getFormattedExchangeRateInfo() async { final rate = await getUsdToKrwRate(); @@ -76,11 +125,42 @@ class ExchangeRateService { symbol: '₩', decimalDigits: 0, ).format(rate); - return '오늘 기준 환율 : $formattedRate'; + return formattedRate; } return ''; } + /// 언어별 환율 정보를 포맷팅하여 반환합니다. + Future 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로 변환하여 포맷팅된 문자열로 반환합니다. Future getFormattedKrwAmount(double usdAmount) async { final krwAmount = await convertUsdToKrw(usdAmount); @@ -94,4 +174,46 @@ class ExchangeRateService { } return ''; } + + /// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다. + Future 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 ''; + } } diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 2808949..83af72d 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -76,7 +76,7 @@ class SmsScanner { try { final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; 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 actualRepeatCount = repeatCount > 0 ? repeatCount : 1; @@ -142,7 +142,7 @@ class SmsScanner { } // 결제 주기별 다음 결제일 계산 - if (billingCycle == '월간') { + if (billingCycle == 'monthly') { int month = now.month; int year = now.year; @@ -156,7 +156,7 @@ class SmsScanner { } return DateTime(year, month, billingDate.day); - } else if (billingCycle == '연간') { + } else if (billingCycle == 'yearly') { // 올해의 결제일이 지났는지 확인 final thisYearBilling = DateTime(now.year, billingDate.month, billingDate.day); @@ -165,7 +165,7 @@ class SmsScanner { } else { return thisYearBilling; } - } else if (billingCycle == '주간') { + } else if (billingCycle == 'weekly') { // 가장 가까운 다음 주 같은 요일 계산 final dayDifference = billingDate.weekday - now.weekday; final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference; diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index d9c676c..a6c46eb 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -747,6 +747,60 @@ class SubscriptionUrlMatcher { return _getCategoryForLegacyService(serviceName); } + /// 현재 로케일에 따라 서비스 표시명 가져오기 + static Future 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; + + // JSON에서 서비스 찾기 + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final data = serviceData as Map; + final names = List.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로 매핑 static String _getCategoryIdByKey(String key) { // 여기에 실제 앱의 카테고리 ID 매핑을 추가 diff --git a/lib/utils/subscription_category_helper.dart b/lib/utils/subscription_category_helper.dart index 3620185..fff798e 100644 --- a/lib/utils/subscription_category_helper.dart +++ b/lib/utils/subscription_category_helper.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; import '../services/subscription_url_matcher.dart'; @@ -8,11 +9,13 @@ class SubscriptionCategoryHelper { /// /// [subscriptions] 구독 목록 /// [categoryProvider] 카테고리 제공자 + /// [context] BuildContext for localization /// /// 반환값: 카테고리 이름을 키로 하고 해당 카테고리에 속하는 구독 목록을 값으로 가지는 Map static Map> categorizeSubscriptions( List subscriptions, CategoryProvider categoryProvider, + BuildContext context, ) { final Map> categorizedSubscriptions = {}; @@ -36,89 +39,89 @@ class SubscriptionCategoryHelper { // 음악 if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.musicServices)) { - if (!categorizedSubscriptions.containsKey('음악')) { - categorizedSubscriptions['음악'] = []; + if (!categorizedSubscriptions.containsKey('music')) { + categorizedSubscriptions['music'] = []; } - categorizedSubscriptions['음악']!.add(subscription); + categorizedSubscriptions['music']!.add(subscription); } // OTT(동영상) else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.ottServices)) { - if (!categorizedSubscriptions.containsKey('OTT(동영상)')) { - categorizedSubscriptions['OTT(동영상)'] = []; + if (!categorizedSubscriptions.containsKey('ottVideo')) { + categorizedSubscriptions['ottVideo'] = []; } - categorizedSubscriptions['OTT(동영상)']!.add(subscription); + categorizedSubscriptions['ottVideo']!.add(subscription); } // 저장/클라우드 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.storageServices)) { - if (!categorizedSubscriptions.containsKey('저장/클라우드')) { - categorizedSubscriptions['저장/클라우드'] = []; + if (!categorizedSubscriptions.containsKey('storageCloud')) { + categorizedSubscriptions['storageCloud'] = []; } - categorizedSubscriptions['저장/클라우드']!.add(subscription); + categorizedSubscriptions['storageCloud']!.add(subscription); } // 통신 · 인터넷 · TV else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.telecomServices)) { - if (!categorizedSubscriptions.containsKey('통신 · 인터넷 · TV')) { - categorizedSubscriptions['통신 · 인터넷 · TV'] = []; + if (!categorizedSubscriptions.containsKey('telecomInternetTv')) { + categorizedSubscriptions['telecomInternetTv'] = []; } - categorizedSubscriptions['통신 · 인터넷 · TV']!.add(subscription); + categorizedSubscriptions['telecomInternetTv']!.add(subscription); } // 생활/라이프스타일 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.lifestyleServices)) { - if (!categorizedSubscriptions.containsKey('생활/라이프스타일')) { - categorizedSubscriptions['생활/라이프스타일'] = []; + if (!categorizedSubscriptions.containsKey('lifestyle')) { + categorizedSubscriptions['lifestyle'] = []; } - categorizedSubscriptions['생활/라이프스타일']!.add(subscription); + categorizedSubscriptions['lifestyle']!.add(subscription); } // 쇼핑/이커머스 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.shoppingServices)) { - if (!categorizedSubscriptions.containsKey('쇼핑/이커머스')) { - categorizedSubscriptions['쇼핑/이커머스'] = []; + if (!categorizedSubscriptions.containsKey('shoppingEcommerce')) { + categorizedSubscriptions['shoppingEcommerce'] = []; } - categorizedSubscriptions['쇼핑/이커머스']!.add(subscription); + categorizedSubscriptions['shoppingEcommerce']!.add(subscription); } // 프로그래밍 else if (_isInCategory(subscription.serviceName, SubscriptionUrlMatcher.programmingServices)) { - if (!categorizedSubscriptions.containsKey('프로그래밍')) { - categorizedSubscriptions['프로그래밍'] = []; + if (!categorizedSubscriptions.containsKey('programming')) { + categorizedSubscriptions['programming'] = []; } - categorizedSubscriptions['프로그래밍']!.add(subscription); + categorizedSubscriptions['programming']!.add(subscription); } // 협업/오피스 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.officeTools)) { - if (!categorizedSubscriptions.containsKey('협업/오피스')) { - categorizedSubscriptions['협업/오피스'] = []; + if (!categorizedSubscriptions.containsKey('collaborationOffice')) { + categorizedSubscriptions['collaborationOffice'] = []; } - categorizedSubscriptions['협업/오피스']!.add(subscription); + categorizedSubscriptions['collaborationOffice']!.add(subscription); } // AI 서비스 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.aiServices)) { - if (!categorizedSubscriptions.containsKey('AI 서비스')) { - categorizedSubscriptions['AI 서비스'] = []; + if (!categorizedSubscriptions.containsKey('aiService')) { + categorizedSubscriptions['aiService'] = []; } - categorizedSubscriptions['AI 서비스']!.add(subscription); + categorizedSubscriptions['aiService']!.add(subscription); } // 기타 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.otherServices)) { - if (!categorizedSubscriptions.containsKey('기타')) { - categorizedSubscriptions['기타'] = []; + if (!categorizedSubscriptions.containsKey('other')) { + categorizedSubscriptions['other'] = []; } - categorizedSubscriptions['기타']!.add(subscription); + categorizedSubscriptions['other']!.add(subscription); } // 미분류된 서비스 else { - if (!categorizedSubscriptions.containsKey('미분류')) { - categorizedSubscriptions['미분류'] = []; + if (!categorizedSubscriptions.containsKey('uncategorized')) { + categorizedSubscriptions['uncategorized'] = []; } - categorizedSubscriptions['미분류']!.add(subscription); + categorizedSubscriptions['uncategorized']!.add(subscription); } } diff --git a/lib/widgets/add_subscription/add_subscription_app_bar.dart b/lib/widgets/add_subscription/add_subscription_app_bar.dart index bf07481..4be1c8b 100644 --- a/lib/widgets/add_subscription/add_subscription_app_bar.dart +++ b/lib/widgets/add_subscription/add_subscription_app_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'dart:math' as math; import '../../controllers/add_subscription_controller.dart'; +import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 App Bar class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -49,7 +50,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg onPressed: () => Navigator.of(context).pop(), ), title: Text( - '구독 추가', + AppLocalizations.of(context).addSubscription, style: TextStyle( fontFamily: 'Montserrat', fontSize: 24, @@ -93,7 +94,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg color: Color(0xFF3B82F6), ), onPressed: onScanSMS, - tooltip: 'SMS에서 구독 정보 스캔', + tooltip: AppLocalizations.of(context).scanTextMessages, ), ], ), diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart index 796ae1a..1ab39e2 100644 --- a/lib/widgets/add_subscription/add_subscription_event_section.dart +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -3,6 +3,7 @@ import '../../controllers/add_subscription_controller.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; import '../../theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 이벤트/할인 섹션 class AddSubscriptionEventSection extends StatelessWidget { @@ -75,13 +76,32 @@ class AddSubscriptionEventSection extends StatelessWidget { ), ), const SizedBox(width: 12), - const Text( - '이벤트 가격', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.darkNavy, - ), + Builder( + builder: (context) { + 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, + fontWeight: FontWeight.w700, + color: AppColors.darkNavy, + ), + ); + }, ), ], ), @@ -133,13 +153,32 @@ class AddSubscriptionEventSection extends StatelessWidget { ), const SizedBox(width: 8), Expanded( - child: Text( - '할인 또는 프로모션 가격을 설정하세요', - style: TextStyle( - fontSize: 14, - color: AppColors.darkNavy, - fontWeight: FontWeight.w500, - ), + 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( + fontSize: 14, + color: AppColors.darkNavy, + fontWeight: FontWeight.w500, + ), + ); + }, ), ), ], @@ -148,35 +187,88 @@ class AddSubscriptionEventSection extends StatelessWidget { const SizedBox(height: 20), // 이벤트 기간 - DateRangePickerField( - startDate: controller.eventStartDate, - endDate: controller.eventEndDate, - onStartDateSelected: (date) { - setState(() { - controller.eventStartDate = date; - // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 - if (date != null && controller.eventEndDate == null) { - controller.eventEndDate = date.add(const Duration(days: 30)); - } - }); + 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, + endDate: controller.eventEndDate, + onStartDateSelected: (date) { + setState(() { + controller.eventStartDate = date; + // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 + if (date != null && controller.eventEndDate == null) { + controller.eventEndDate = date.add(const Duration(days: 30)); + } + }); + }, + onEndDateSelected: (date) { + setState(() { + controller.eventEndDate = date; + }); + }, + startLabel: startLabel, + endLabel: endLabel, + primaryColor: controller.gradientColors[0], + ); }, - onEndDateSelected: (date) { - setState(() { - controller.eventEndDate = date; - }); - }, - startLabel: '시작일', - endLabel: '종료일', - primaryColor: controller.gradientColors[0], ), const SizedBox(height: 20), // 이벤트 가격 - CurrencyInputField( - controller: controller.eventPriceController, - currency: controller.currency, - label: '이벤트 가격', - hintText: '할인된 가격을 입력하세요', + 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, + currency: controller.currency, + label: eventPriceLabel, + hintText: eventPriceHint, + ); + }, ), ], ), diff --git a/lib/widgets/add_subscription/add_subscription_form.dart b/lib/widgets/add_subscription/add_subscription_form.dart index 87a6773..1371625 100644 --- a/lib/widgets/add_subscription/add_subscription_form.dart +++ b/lib/widgets/add_subscription/add_subscription_form.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../../controllers/add_subscription_controller.dart'; import '../../providers/category_provider.dart'; +import '../../l10n/app_localizations.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; @@ -67,9 +68,9 @@ class AddSubscriptionForm extends StatelessWidget { ), ), const SizedBox(width: 12), - const Text( - '서비스 정보', - style: TextStyle( + Text( + AppLocalizations.of(context).serviceInfo, + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.5, @@ -84,15 +85,15 @@ class AddSubscriptionForm extends StatelessWidget { BaseTextField( controller: controller.serviceNameController, focusNode: controller.serviceNameFocus, - label: '서비스명', - hintText: '예: Netflix, Spotify', + label: AppLocalizations.of(context).labelServiceName, + hintText: AppLocalizations.of(context).hintServiceName, textInputAction: TextInputAction.next, onEditingComplete: () { controller.monthlyCostFocus.requestFocus(); }, validator: (value) { if (value == null || value.isEmpty) { - return '서비스명을 입력해주세요'; + return AppLocalizations.of(context).serviceNameRequired; } return null; }, @@ -108,7 +109,7 @@ class AddSubscriptionForm extends StatelessWidget { child: CurrencyInputField( controller: controller.monthlyCostController, currency: controller.currency, - label: '월 지출', + label: AppLocalizations.of(context).labelMonthlyExpense, focusNode: controller.monthlyCostFocus, textInputAction: TextInputAction.next, onEditingComplete: () { @@ -116,7 +117,7 @@ class AddSubscriptionForm extends StatelessWidget { }, validator: (value) { if (value == null || value.isEmpty) { - return '금액을 입력해주세요'; + return AppLocalizations.of(context).amountRequired; } return null; }, @@ -127,9 +128,9 @@ class AddSubscriptionForm extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '통화', - style: TextStyle( + Text( + AppLocalizations.of(context).currency, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), @@ -155,9 +156,10 @@ class AddSubscriptionForm extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '결제 주기', - style: TextStyle( + Text( + AppLocalizations.of(context).billingCycle, + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w600, ), @@ -185,7 +187,7 @@ class AddSubscriptionForm extends StatelessWidget { controller.nextBillingDate = date; }); }, - label: '다음 결제일', + label: AppLocalizations.of(context).nextBillingDate, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365 * 2)), primaryColor: controller.gradientColors[0], @@ -196,8 +198,8 @@ class AddSubscriptionForm extends StatelessWidget { BaseTextField( controller: controller.websiteUrlController, focusNode: controller.websiteUrlFocus, - label: '웹사이트 URL (선택)', - hintText: 'https://example.com', + label: AppLocalizations.of(context).websiteUrlOptional, + hintText: AppLocalizations.of(context).hintWebsiteUrl, keyboardType: TextInputType.url, prefixIcon: Icon( Icons.link_rounded, @@ -212,9 +214,9 @@ class AddSubscriptionForm extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '카테고리', - style: TextStyle( + Text( + AppLocalizations.of(context).category, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), @@ -243,4 +245,3 @@ class AddSubscriptionForm extends StatelessWidget { ); } } - diff --git a/lib/widgets/add_subscription/add_subscription_header.dart b/lib/widgets/add_subscription/add_subscription_header.dart index 84eaa6f..1b52cd1 100644 --- a/lib/widgets/add_subscription/add_subscription_header.dart +++ b/lib/widgets/add_subscription/add_subscription_header.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../controllers/add_subscription_controller.dart'; +import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 헤더 섹션 class AddSubscriptionHeader extends StatelessWidget { @@ -54,23 +55,23 @@ class AddSubscriptionHeader extends StatelessWidget { ), ), const SizedBox(width: 16), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '새 구독 추가', - style: TextStyle( + AppLocalizations.of(context).newSubscriptionAdd, + style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w800, color: Colors.white, letterSpacing: -0.5, ), ), - SizedBox(height: 4), + const SizedBox(height: 4), Text( - '서비스 정보를 입력해주세요', - style: TextStyle( + AppLocalizations.of(context).enterServiceInfo, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white70, diff --git a/lib/widgets/add_subscription/add_subscription_save_button.dart b/lib/widgets/add_subscription/add_subscription_save_button.dart index b924956..829b551 100644 --- a/lib/widgets/add_subscription/add_subscription_save_button.dart +++ b/lib/widgets/add_subscription/add_subscription_save_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../controllers/add_subscription_controller.dart'; import '../common/buttons/primary_button.dart'; +import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 저장 버튼 class AddSubscriptionSaveButton extends StatelessWidget { @@ -37,7 +38,7 @@ class AddSubscriptionSaveButton extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(bottom: 80), child: PrimaryButton( - text: '구독 추가하기', + text: AppLocalizations.of(context).addSubscriptionButton, icon: Icons.add_circle_outline, onPressed: controller.isLoading ? null diff --git a/lib/widgets/analysis/analysis_badge.dart b/lib/widgets/analysis/analysis_badge.dart index c28a073..5c5ed09 100644 --- a/lib/widgets/analysis/analysis_badge.dart +++ b/lib/widgets/analysis/analysis_badge.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; +import 'package:provider/provider.dart'; import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; +import '../../providers/locale_provider.dart'; import '../../theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 class AnalysisBadge extends StatelessWidget { @@ -54,17 +57,26 @@ class AnalysisBadge extends StatelessWidget { ), const SizedBox(height: 0), FutureBuilder( - future: CurrencyUtil.formatAmount( + future: CurrencyUtil.formatAmountWithLocale( subscription.monthlyCost, subscription.currency, + context.read().locale.languageCode, ), builder: (context, snapshot) { if (snapshot.hasData) { final amountText = snapshot.data!; - // 금액이 너무 길면 축약 - final displayText = amountText.length > 8 - ? amountText.replaceAll('원', '').trim() - : amountText; + // 금액이 너무 길면 축약 (괄호 제거) + String displayText = amountText; + if (amountText.length > 12) { + // 괄호 안의 내용 제거 + 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( displayText, style: const TextStyle( diff --git a/lib/widgets/analysis/event_analysis_card.dart b/lib/widgets/analysis/event_analysis_card.dart index 3d8ef4a..2dfe07e 100644 --- a/lib/widgets/analysis/event_analysis_card.dart +++ b/lib/widgets/analysis/event_analysis_card.dart @@ -6,6 +6,7 @@ import '../../services/currency_util.dart'; import '../../theme/app_colors.dart'; import '../glassmorphism_card.dart'; import '../themed_text.dart'; +import '../../l10n/app_localizations.dart'; /// 이벤트 할인 현황을 보여주는 카드 위젯 class EventAnalysisCard extends StatelessWidget { @@ -50,7 +51,7 @@ class EventAnalysisCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: '이벤트 할인 현황', + text: AppLocalizations.of(context).eventDiscountStatus, style: const TextStyle( fontSize: 18, ), @@ -78,7 +79,7 @@ class EventAnalysisCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - '${provider.activeEventSubscriptions.length}개 진행중', + AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -119,9 +120,9 @@ class EventAnalysisCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ThemedText( - '월간 절약 금액', - style: TextStyle( + ThemedText( + AppLocalizations.of(context).monthlySavingAmount, + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, ), @@ -144,9 +145,9 @@ class EventAnalysisCard extends StatelessWidget { ), ), const SizedBox(height: 16), - const ThemedText( - '진행중인 이벤트', - style: TextStyle( + ThemedText( + AppLocalizations.of(context).eventsInProgress, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, ), @@ -246,7 +247,7 @@ class EventAnalysisCard extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: Text( - '$discountRate% 할인', + '$discountRate${AppLocalizations.of(context).discountPercent}', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, diff --git a/lib/widgets/analysis/monthly_expense_chart_card.dart b/lib/widgets/analysis/monthly_expense_chart_card.dart index 1e1f20d..edbe059 100644 --- a/lib/widgets/analysis/monthly_expense_chart_card.dart +++ b/lib/widgets/analysis/monthly_expense_chart_card.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import 'dart:math' as math; +import 'package:provider/provider.dart'; import '../../services/currency_util.dart'; +import '../../providers/locale_provider.dart'; import '../../theme/app_colors.dart'; import '../glassmorphism_card.dart'; import '../themed_text.dart'; +import '../../l10n/app_localizations.dart'; /// 월별 지출 현황을 차트로 보여주는 카드 위젯 class MonthlyExpenseChartCard extends StatelessWidget { @@ -17,12 +20,64 @@ class MonthlyExpenseChartCard extends StatelessWidget { 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 _getMonthlyBarGroups() { + List _getMonthlyBarGroups(String locale) { final List barGroups = []; final calculatedMax = monthlyData.fold( 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++) { final data = monthlyData[i]; @@ -44,7 +99,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { borderRadius: BorderRadius.circular(4), backDrawRodData: BackgroundBarChartRodData( show: true, - toY: maxAmount + (maxAmount * 0.1), + toY: maxAmount, color: AppColors.navyGray.withValues(alpha: 0.1), ), ), @@ -58,6 +113,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = context.watch().locale.languageCode; return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -84,14 +140,14 @@ class MonthlyExpenseChartCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ThemedText.headline( - text: '월별 지출 현황', + text: AppLocalizations.of(context).monthlyExpenseTitle, style: const TextStyle( fontSize: 18, ), ), const SizedBox(height: 8), ThemedText.subtitle( - text: '최근 6개월간 추이', + text: AppLocalizations.of(context).recentSixMonthsTrend, style: const TextStyle( fontSize: 14, ), @@ -103,25 +159,26 @@ class MonthlyExpenseChartCard extends StatelessWidget { child: BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, - maxY: math.max( + maxY: _calculateChartMaxY( monthlyData.fold( 0, (max, data) => math.max( - max, data['totalExpense'] as double)) * - 1.2, - 100000.0 // 최소값 10만원 + max, data['totalExpense'] as double)), + locale ), - barGroups: _getMonthlyBarGroups(), + barGroups: _getMonthlyBarGroups(locale), gridData: FlGridData( show: true, drawVerticalLine: false, - horizontalInterval: math.max( - monthlyData.fold( + horizontalInterval: _calculateGridInterval( + _calculateChartMaxY( + monthlyData.fold( 0, (max, data) => math.max(max, - data['totalExpense'] as double)) / - 4, - 25000.0 // 최소 간격 2.5만원 + data['totalExpense'] as double)), + locale + ), + CurrencyUtil.getDefaultCurrency(locale) ), getDrawingHorizontalLine: (value) { return FlLine( @@ -176,9 +233,10 @@ class MonthlyExpenseChartCard extends StatelessWidget { ), children: [ TextSpan( - text: CurrencyUtil.formatTotalAmount( + text: CurrencyUtil.formatTotalAmountWithLocale( monthlyData[group.x]['totalExpense'] - as double), + as double, + locale), style: const TextStyle( color: Color(0xFFFBBF24), fontSize: 14, @@ -196,7 +254,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { const SizedBox(height: 16), Center( child: ThemedText.caption( - text: '월 구독 지출', + text: AppLocalizations.of(context).monthlySubscriptionExpense, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, diff --git a/lib/widgets/analysis/subscription_pie_chart_card.dart b/lib/widgets/analysis/subscription_pie_chart_card.dart index b43aff2..17cf530 100644 --- a/lib/widgets/analysis/subscription_pie_chart_card.dart +++ b/lib/widgets/analysis/subscription_pie_chart_card.dart @@ -1,72 +1,175 @@ import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; +import 'package:provider/provider.dart'; import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; +import '../../services/exchange_rate_service.dart'; import '../../theme/app_colors.dart'; import '../glassmorphism_card.dart'; import '../themed_text.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 subscriptions; final AnimationController animationController; - final int touchedIndex; - final Function(int) onPieTouch; const SubscriptionPieChartCard({ super.key, required this.subscriptions, required this.animationController, - required this.touchedIndex, - required this.onPieTouch, }); - // 파이 차트 섹션 데이터 - List _getPieSections() { - if (subscriptions.isEmpty) return []; + @override + State createState() => _SubscriptionPieChartCardState(); +} - final colors = [ - const Color(0xFF3B82F6), - const Color(0xFF10B981), - const Color(0xFFF59E0B), - const Color(0xFFEF4444), - const Color(0xFF8B5CF6), - const Color(0xFF0EA5E9), - const Color(0xFFEC4899), - ]; +class _SubscriptionPieChartCardState extends State { + int _touchedIndex = -1; + late Future> _pieSectionsFuture; + String? _lastLocale; + + static const _chartColors = [ + Color(0xFF3B82F6), + 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().locale.languageCode; + if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) || + _lastLocale != currentLocale) { + _initializeFuture(); + } + } + + void _initializeFuture() { + _lastLocale = context.read().locale.languageCode; + _pieSectionsFuture = _getPieSections(); + } + + bool _listEquals(List a, List 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> _getPieSections() async { + + if (widget.subscriptions.isEmpty) return []; + + // 현재 locale 가져오기 + final locale = context.read().locale.languageCode; + final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); + + // 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산) List sectionValues = []; - // 각 구독의 원화 환산 금액 또는 원화 금액을 계산 - for (var subscription in subscriptions) { - double value = subscription.monthlyCost; - if (subscription.currency == 'USD') { - // USD의 경우 마지막으로 조회된 환율로 대략적인 계산 - // (정확한 계산은 비동기로 이루어지므로 UI 표시용으로만 사용) - const rate = 1350.0; // 기본 환율 (실제 값은 API로 별도로 가져옴) - value = value * rate; + // 각 구독의 현재 가격을 언어별 기본 통화로 환산 + for (var subscription in widget.subscriptions) { + double value = subscription.currentPrice; + + if (subscription.currency == defaultCurrency) { + // 이미 기본 통화인 경우 그대로 사용 + 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); } - sectionValues.add(value); } // 총합 계산 double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); + + // 총합이 0이면 빈 배열 반환 + if (sectionsTotal == 0) return []; - // 섹션 데이터 생성 - return List.generate(subscriptions.length, (i) { - final subscription = subscriptions[i]; + // 섹션 데이터 생성 (터치 상태 제외) + final sections = List.generate(widget.subscriptions.length, (i) { final percentage = (sectionValues[i] / sectionsTotal) * 100; - final index = i % colors.length; - final isTouched = touchedIndex == i; - final fontSize = isTouched ? 16.0 : 12.0; - final radius = isTouched ? 105.0 : 100.0; + final index = i % _chartColors.length; return PieChartSectionData( value: sectionValues[i], 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 _applyTouchedState(List 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, fontWeight: FontWeight.bold, color: AppColors.pureWhite, @@ -74,17 +177,11 @@ class SubscriptionPieChartCard extends StatelessWidget { Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) ], ), - color: colors[index], + color: section.color, radius: radius, - titlePositionPercentageOffset: 0.6, - badgeWidget: isTouched - ? AnalysisBadge( - size: 40, - borderColor: colors[index], - subscription: subscription, - ) - : null, - badgePositionPercentageOffset: .98, + titlePositionPercentageOffset: section.titlePositionPercentageOffset, + badgeWidget: isTouched ? _createBadgeWidget(i) : null, + badgePositionPercentageOffset: section.badgePositionPercentageOffset, ); }); } @@ -96,7 +193,7 @@ class SubscriptionPieChartCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: FadeTransition( opacity: CurvedAnimation( - parent: animationController, + parent: widget.animationController, curve: const Interval(0.0, 0.7, curve: Curves.easeOut), ), child: SlideTransition( @@ -104,7 +201,7 @@ class SubscriptionPieChartCard extends StatelessWidget { begin: const Offset(0, 0.2), end: Offset.zero, ).animate(CurvedAnimation( - parent: animationController, + parent: widget.animationController, curve: const Interval(0.0, 0.7, curve: Curves.easeOut), )), child: GlassmorphismCard( @@ -120,13 +217,15 @@ class SubscriptionPieChartCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: '구독 서비스 비율', + text: AppLocalizations.of(context).subscriptionServiceRatio, style: const TextStyle( fontSize: 18, ), ), FutureBuilder( - future: CurrencyUtil.getExchangeRateInfo(), + future: CurrencyUtil.getExchangeRateInfoForLocale( + context.watch().locale.languageCode + ), builder: (context, snapshot) { if (snapshot.hasData && snapshot.data!.isNotEmpty) { @@ -145,7 +244,7 @@ class SubscriptionPieChartCard extends StatelessWidget { ), ), child: Text( - snapshot.data!, + AppLocalizations.of(context).exchangeRateFormat(snapshot.data!), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -161,20 +260,20 @@ class SubscriptionPieChartCard extends StatelessWidget { ), const SizedBox(height: 8), ThemedText.subtitle( - text: '월 지출 기준', + text: AppLocalizations.of(context).monthlyExpenseBasis, style: const TextStyle( fontSize: 14, ), ), const SizedBox(height: 16), Center( - child: subscriptions.isEmpty - ? const SizedBox( + child: widget.subscriptions.isEmpty + ? SizedBox( height: 250, child: Center( child: ThemedText( - '구독중인 서비스가 없습니다', - style: TextStyle( + AppLocalizations.of(context).noSubscriptionServices, + style: const TextStyle( fontSize: 16, ), ), @@ -182,52 +281,90 @@ class SubscriptionPieChartCard extends StatelessWidget { ) : SizedBox( height: 250, - child: PieChart( - PieChartData( - borderData: FlBorderData(show: false), - sectionsSpace: 2, - centerSpaceRadius: 60, - sections: _getPieSections(), - pieTouchData: PieTouchData( - touchCallback: (FlTouchEvent event, - pieTouchResponse) { - if (!event - .isInterestedForInteractions || - pieTouchResponse == null || - pieTouchResponse - .touchedSection == - null) { - onPieTouch(-1); - return; - } - onPieTouch(pieTouchResponse - .touchedSection! - .touchedSectionIndex); - }, - ), - ), + child: FutureBuilder>( + 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( + borderData: FlBorderData(show: false), + sectionsSpace: 2, + centerSpaceRadius: 60, + sections: _applyTouchedState(snapshot.data!), + pieTouchData: PieTouchData( + enabled: true, + touchCallback: (FlTouchEvent event, + pieTouchResponse) { + // 터치 응답이 없거나 섹션이 없는 경우 + if (pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + // 차트 밖으로 나갔을 때만 리셋 + if (_touchedIndex != -1) { + setState(() { + _touchedIndex = -1; + }); + } + return; + } + + final touchedIndex = pieTouchResponse + .touchedSection! + .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), // 서비스 목록 Column( - children: subscriptions.isEmpty + children: widget.subscriptions.isEmpty ? [] : List.generate( - subscriptions.length, + widget.subscriptions.length, (index) { final subscription = - subscriptions[index]; - final color = [ - const Color(0xFF3B82F6), - const Color(0xFF10B981), - const Color(0xFFF59E0B), - const Color(0xFFEF4444), - const Color(0xFF8B5CF6), - const Color(0xFF0EA5E9), - const Color(0xFFEC4899), - ][index % 7]; + widget.subscriptions[index]; + final color = _chartColors[index % _chartColors.length]; return Padding( padding: const EdgeInsets.only( bottom: 4.0), @@ -254,8 +391,9 @@ class SubscriptionPieChartCard extends StatelessWidget { ), FutureBuilder( future: CurrencyUtil - .formatSubscriptionAmount( - subscription), + .formatSubscriptionAmountWithLocale( + subscription, + context.read().locale.languageCode), builder: (context, snapshot) { if (snapshot.hasData) { return ThemedText( diff --git a/lib/widgets/analysis/total_expense_summary_card.dart b/lib/widgets/analysis/total_expense_summary_card.dart index b55923b..2e06d94 100644 --- a/lib/widgets/analysis/total_expense_summary_card.dart +++ b/lib/widgets/analysis/total_expense_summary_card.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; +import '../../providers/locale_provider.dart'; import '../../utils/haptic_feedback_helper.dart'; import '../../theme/app_colors.dart'; import '../glassmorphism_card.dart'; import '../themed_text.dart'; +import '../../l10n/app_localizations.dart'; /// 총 지출 요약을 보여주는 카드 위젯 class TotalExpenseSummaryCard extends StatelessWidget { @@ -23,6 +26,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = context.watch().locale.languageCode; return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -52,7 +56,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: '총 지출 요약', + text: AppLocalizations.of(context).totalExpenseSummary, style: const TextStyle( fontSize: 18, ), @@ -63,14 +67,14 @@ class TotalExpenseSummaryCard extends StatelessWidget { padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () async { - final totalExpenseText = CurrencyUtil.formatTotalAmount(totalExpense); + final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale); await Clipboard.setData( ClipboardData(text: totalExpenseText)); HapticFeedbackHelper.lightImpact(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('총 지출액이 복사되었습니다: $totalExpenseText'), + content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)), duration: const Duration(seconds: 2), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( @@ -89,7 +93,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), const SizedBox(height: 8), ThemedText.subtitle( - text: '월 단위 총액', + text: AppLocalizations.of(context).monthlyTotalAmount, style: const TextStyle( fontSize: 14, ), @@ -103,7 +107,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ThemedText.caption( - text: '총 지출', + text: AppLocalizations.of(context).totalExpense, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -111,7 +115,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), const SizedBox(height: 4), ThemedText( - CurrencyUtil.formatTotalAmount(totalExpense), + CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale), style: const TextStyle( fontSize: 26, fontWeight: FontWeight.bold, @@ -148,7 +152,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { CrossAxisAlignment.start, children: [ ThemedText.caption( - text: '총 서비스', + text: AppLocalizations.of(context).totalServices, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -156,7 +160,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), const SizedBox(height: 2), ThemedText( - '${subscriptions.length}개', + AppLocalizations.of(context).subscriptionCount(subscriptions.length), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -190,7 +194,7 @@ class TotalExpenseSummaryCard extends StatelessWidget { CrossAxisAlignment.start, children: [ ThemedText.caption( - text: '평균 요금', + text: AppLocalizations.of(context).averageCost, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -198,10 +202,11 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), const SizedBox(height: 2), ThemedText( - CurrencyUtil.formatTotalAmount( + CurrencyUtil.formatTotalAmountWithLocale( subscriptions.isEmpty ? 0 - : totalExpense / subscriptions.length), + : totalExpense / subscriptions.length, + locale), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index 1215034..7e7a01b 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -7,6 +7,7 @@ import '../models/subscription_model.dart'; import '../providers/navigation_provider.dart'; import '../routes/app_routes.dart'; import 'animated_page_transitions.dart'; +import '../l10n/app_localizations.dart'; /// 앱 전체의 네비게이션을 관리하는 클래스 class AppNavigator { @@ -118,16 +119,16 @@ class AppNavigator { final shouldExit = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('앱 종료'), - content: const Text('SubManager를 종료하시겠습니까?'), + title: Text(AppLocalizations.of(context).exitApp), + content: Text(AppLocalizations.of(context).exitAppConfirm), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('취소'), + child: Text(AppLocalizations.of(context).cancel), ), TextButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text('종료'), + child: Text(AppLocalizations.of(context).exit), ), ], ), diff --git a/lib/widgets/category_header_widget.dart b/lib/widgets/category_header_widget.dart index 8971c58..85fd35c 100644 --- a/lib/widgets/category_header_widget.dart +++ b/lib/widgets/category_header_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import '../l10n/app_localizations.dart'; /// 카테고리별 구독 그룹의 헤더 위젯 /// @@ -10,6 +11,8 @@ class CategoryHeaderWidget extends StatelessWidget { final int subscriptionCount; final double totalCostUSD; final double totalCostKRW; + final double totalCostJPY; + final double totalCostCNY; const CategoryHeaderWidget({ Key? key, @@ -17,6 +20,8 @@ class CategoryHeaderWidget extends StatelessWidget { required this.subscriptionCount, required this.totalCostUSD, required this.totalCostKRW, + required this.totalCostJPY, + required this.totalCostCNY, }) : super(key: key); @override @@ -38,7 +43,7 @@ class CategoryHeaderWidget extends StatelessWidget { ), ), Text( - _buildCostDisplay(), + _buildCostDisplay(context), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -59,11 +64,11 @@ class CategoryHeaderWidget extends StatelessWidget { } /// 통화별 합계를 표시하는 문자열을 생성합니다. - String _buildCostDisplay() { + String _buildCostDisplay(BuildContext context) { final parts = []; // 개수는 항상 표시 - parts.add('$subscriptionCount개'); + parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); // 통화 부분을 별도로 처리 final currencyParts = []; @@ -88,6 +93,26 @@ class CategoryHeaderWidget extends StatelessWidget { 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) { // 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로 diff --git a/lib/widgets/common/form_fields/billing_cycle_selector.dart b/lib/widgets/common/form_fields/billing_cycle_selector.dart index 513ea37..97315c4 100644 --- a/lib/widgets/common/form_fields/billing_cycle_selector.dart +++ b/lib/widgets/common/form_fields/billing_cycle_selector.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; /// 결제 주기 선택 위젯 /// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다. @@ -21,10 +22,21 @@ class BillingCycleSelector extends StatelessWidget { @override Widget build(BuildContext context) { + final localization = AppLocalizations.of(context); // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 final cycles = isGlassmorphism - ? ['매월', '분기별', '반기별', '매년'] - : ['월간', '분기별', '반기별', '연간']; + ? [ + localization.billingCycleMonthly, + localization.billingCycleQuarterly, + localization.billingCycleHalfYearly, + localization.billingCycleYearly, + ] + : [ + localization.monthly, + localization.billingCycleQuarterly, + localization.billingCycleHalfYearly, + localization.yearly, + ]; return SingleChildScrollView( scrollDirection: Axis.horizontal, diff --git a/lib/widgets/common/form_fields/category_selector.dart b/lib/widgets/common/form_fields/category_selector.dart index ea82d31..949bc52 100644 --- a/lib/widgets/common/form_fields/category_selector.dart +++ b/lib/widgets/common/form_fields/category_selector.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../../theme/app_colors.dart'; +import '../../../providers/category_provider.dart'; /// 카테고리 선택 위젯 /// 구독 서비스의 카테고리를 선택할 수 있습니다. @@ -50,13 +52,17 @@ class CategorySelector extends StatelessWidget { color: _getTextColor(isSelected), ), const SizedBox(width: 6), - Text( - category.name, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: _getTextColor(isSelected), - ), + Consumer( + builder: (context, categoryProvider, child) { + return Text( + categoryProvider.getLocalizedCategoryName(context, category.name), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: _getTextColor(isSelected), + ), + ); + }, ), ], ), @@ -69,25 +75,25 @@ class CategorySelector extends StatelessWidget { IconData _getCategoryIcon(dynamic category) { // 카테고리명에 따른 아이콘 반환 switch (category.name) { - case '음악': + case 'music': return Icons.music_note_rounded; - case 'OTT(동영상)': + case 'ottVideo': return Icons.movie_filter_rounded; - case '저장/클라우드': + case 'storageCloud': return Icons.cloud_outlined; - case '통신 · 인터넷 · TV': + case 'telecomInternetTv': return Icons.wifi_rounded; - case '생활/라이프스타일': + case 'lifestyle': return Icons.home_outlined; - case '쇼핑/이커머스': + case 'shoppingEcommerce': return Icons.shopping_cart_outlined; - case '프로그래밍': + case 'programming': return Icons.code_rounded; - case '협업/오피스': + case 'collaborationOffice': return Icons.business_center_outlined; - case 'AI 서비스': + case 'aiService': return Icons.smart_toy_outlined; - case '기타': + case 'other': default: return Icons.category_outlined; } diff --git a/lib/widgets/common/form_fields/currency_input_field.dart b/lib/widgets/common/form_fields/currency_input_field.dart index 70f5b62..ee407e5 100644 --- a/lib/widgets/common/form_fields/currency_input_field.dart +++ b/lib/widgets/common/form_fields/currency_input_field.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'base_text_field.dart'; +import '../../../l10n/app_localizations.dart'; /// 통화 입력 필드 위젯 /// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다. @@ -112,8 +113,8 @@ class _CurrencyInputFieldState extends State { return widget.currency == 'KRW' ? '₩ ' : '\$ '; } - String get _defaultHintText { - return widget.currency == 'KRW' ? '금액을 입력하세요' : 'Enter amount'; + String _getDefaultHintText(BuildContext context) { + return AppLocalizations.of(context).enterAmount; } @override @@ -122,7 +123,7 @@ class _CurrencyInputFieldState extends State { controller: widget.controller, focusNode: _focusNode, label: widget.label, - hintText: widget.hintText ?? _defaultHintText, + hintText: widget.hintText ?? _getDefaultHintText(context), textInputAction: widget.textInputAction, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ @@ -158,11 +159,11 @@ class _CurrencyInputFieldState extends State { }, validator: widget.validator ?? (value) { if (value == null || value.isEmpty) { - return '금액을 입력해주세요'; + return AppLocalizations.of(context).amountRequired; } final parsedValue = _parseValue(value); if (parsedValue == null || parsedValue <= 0) { - return '올바른 금액을 입력해주세요'; + return AppLocalizations.of(context).invalidAmount; } return null; }, diff --git a/lib/widgets/common/form_fields/currency_selector.dart b/lib/widgets/common/form_fields/currency_selector.dart index ec327df..ec9dd70 100644 --- a/lib/widgets/common/form_fields/currency_selector.dart +++ b/lib/widgets/common/form_fields/currency_selector.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../theme/app_colors.dart'; /// 통화 선택 위젯 -/// KRW(원화)와 USD(달러) 중 선택할 수 있습니다. +/// KRW(원화), USD(달러), JPY(엔화), CNY(위안화) 중 선택할 수 있습니다. class CurrencySelector extends StatelessWidget { final String currency; final ValueChanged onChanged; @@ -17,22 +17,48 @@ class CurrencySelector extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( + return Column( children: [ - _CurrencyOption( - label: '₩', - value: 'KRW', - isSelected: currency == 'KRW', - onTap: () => onChanged('KRW'), - isGlassmorphism: isGlassmorphism, + Row( + children: [ + _CurrencyOption( + label: '₩', + value: 'KRW', + isSelected: currency == 'KRW', + onTap: () => onChanged('KRW'), + isGlassmorphism: isGlassmorphism, + ), + const SizedBox(width: 8), + _CurrencyOption( + label: '\$', + value: 'USD', + isSelected: currency == 'USD', + onTap: () => onChanged('USD'), + isGlassmorphism: isGlassmorphism, + ), + ], ), - const SizedBox(width: 8), - _CurrencyOption( - label: '\$', - value: 'USD', - isSelected: currency == 'USD', - onTap: () => onChanged('USD'), - 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 { final String label; final String value; + final String? subtitle; final bool isSelected; final VoidCallback onTap; final bool isGlassmorphism; @@ -50,6 +77,7 @@ class _CurrencyOption extends StatelessWidget { const _CurrencyOption({ required this.label, required this.value, + this.subtitle, required this.isSelected, required this.onTap, required this.isGlassmorphism, @@ -71,13 +99,29 @@ class _CurrencyOption extends StatelessWidget { border: _getBorder(), ), child: Center( - child: Text( - label, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: _getTextColor(), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: _getTextColor(), + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _getTextColor().withValues(alpha: 0.8), + ), + ), + ], + ], ), ), ), diff --git a/lib/widgets/common/form_fields/date_picker_field.dart b/lib/widgets/common/form_fields/date_picker_field.dart index fcc9d40..61f8886 100644 --- a/lib/widgets/common/form_fields/date_picker_field.dart +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; /// 날짜 선택 필드 위젯 /// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다. @@ -38,7 +39,9 @@ class DatePickerField extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); 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( crossAxisAlignment: CrossAxisAlignment.start, @@ -94,7 +97,7 @@ class DatePickerField extends StatelessWidget { children: [ Expanded( child: Text( - DateFormat(effectiveDateFormat).format(selectedDate), + DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate), style: TextStyle( fontSize: 16, color: enabled @@ -249,8 +252,8 @@ class _DateRangeItem extends StatelessWidget { const SizedBox(height: 4), Text( date != null - ? DateFormat('MM/dd').format(date!) - : '선택', + ? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!) + : AppLocalizations.of(context).dateSelect, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, diff --git a/lib/widgets/detail/detail_action_buttons.dart b/lib/widgets/detail/detail_action_buttons.dart index 4f70b22..5998512 100644 --- a/lib/widgets/detail/detail_action_buttons.dart +++ b/lib/widgets/detail/detail_action_buttons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../controllers/detail_screen_controller.dart'; import '../common/buttons/primary_button.dart'; +import '../../l10n/app_localizations.dart'; /// 상세 화면 액션 버튼 섹션 /// 저장 버튼을 포함하는 섹션입니다. @@ -33,7 +34,7 @@ class DetailActionButtons extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(bottom: 80), child: PrimaryButton( - text: '변경사항 저장', + text: AppLocalizations.of(context).saveChanges, icon: Icons.save_rounded, onPressed: controller.updateSubscription, isLoading: controller.isLoading, diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart index ee68208..b51f3c4 100644 --- a/lib/widgets/detail/detail_event_section.dart +++ b/lib/widgets/detail/detail_event_section.dart @@ -4,6 +4,7 @@ import '../../controllers/detail_screen_controller.dart'; import '../../theme/app_colors.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; +import '../../l10n/app_localizations.dart'; /// 이벤트 가격 섹션 /// 할인 이벤트 정보를 관리하는 섹션입니다. @@ -75,9 +76,9 @@ class DetailEventSection extends StatelessWidget { ), ), const SizedBox(width: 12), - const Text( - '이벤트 가격', - style: TextStyle( + Text( + AppLocalizations.of(context).eventPrice, + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.darkNavy, @@ -125,7 +126,7 @@ class DetailEventSection extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - '할인 또는 프로모션 가격을 설정하세요', + AppLocalizations.of(context).eventPriceHint, style: TextStyle( fontSize: 14, color: AppColors.darkNavy, @@ -151,8 +152,8 @@ class DetailEventSection extends StatelessWidget { onEndDateSelected: (date) { controller.eventEndDate = date; }, - startLabel: '시작일', - endLabel: '종료일', + startLabel: AppLocalizations.of(context).startDate, + endLabel: AppLocalizations.of(context).endDate, primaryColor: baseColor, ), const SizedBox(height: 20), @@ -160,16 +161,16 @@ class DetailEventSection extends StatelessWidget { CurrencyInputField( controller: controller.eventPriceController, currency: controller.currency, - label: '이벤트 가격', - hintText: '할인된 가격을 입력하세요', + label: AppLocalizations.of(context).eventPrice, + hintText: AppLocalizations.of(context).eventPriceHint, validator: controller.isEventActive ? (value) { if (value == null || value.isEmpty) { - return '이벤트 가격을 입력해주세요'; + return AppLocalizations.of(context).eventPriceRequired; } final price = double.tryParse(value.replaceAll(',', '')); if (price == null || price <= 0) { - return '올바른 가격을 입력해주세요'; + return AppLocalizations.of(context).invalidPrice; } return null; } @@ -233,7 +234,7 @@ class _DiscountBadge extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Text( - '$discountPercentage% 할인', + AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -243,9 +244,7 @@ class _DiscountBadge extends StatelessWidget { ), const SizedBox(width: 12), Text( - currency == 'KRW' - ? '₩${discountAmount.toInt().toString()}원 절약' - : '\$${discountAmount.toStringAsFixed(2)} 절약', + _getLocalizedDiscountAmount(context, currency, discountAmount), style: TextStyle( color: const Color(0xFF15803D), 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)); + } + } } \ No newline at end of file diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart index 57e84e8..25b7015 100644 --- a/lib/widgets/detail/detail_form_section.dart +++ b/lib/widgets/detail/detail_form_section.dart @@ -9,6 +9,7 @@ import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/currency_selector.dart'; import '../common/form_fields/billing_cycle_selector.dart'; import '../common/form_fields/category_selector.dart'; +import '../../l10n/app_localizations.dart'; /// 상세 화면 폼 섹션 /// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다. @@ -66,8 +67,8 @@ class DetailFormSection extends StatelessWidget { BaseTextField( controller: controller.serviceNameController, focusNode: controller.serviceNameFocus, - label: '서비스명', - hintText: '예: Netflix, Spotify', + label: AppLocalizations.of(context).subscriptionName, + hintText: AppLocalizations.of(context).serviceNameExample, textInputAction: TextInputAction.next, onEditingComplete: () { controller.monthlyCostFocus.requestFocus(); @@ -84,7 +85,7 @@ class DetailFormSection extends StatelessWidget { child: CurrencyInputField( controller: controller.monthlyCostController, currency: controller.currency, - label: '월 지출', + label: AppLocalizations.of(context).monthlyExpense, focusNode: controller.monthlyCostFocus, textInputAction: TextInputAction.next, onEditingComplete: () { @@ -97,9 +98,9 @@ class DetailFormSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '통화', - style: TextStyle( + Text( + AppLocalizations.of(context).currency, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.darkNavy, @@ -134,9 +135,9 @@ class DetailFormSection extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '결제 주기', - style: TextStyle( + Text( + AppLocalizations.of(context).billingCycle, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.darkNavy, @@ -161,7 +162,7 @@ class DetailFormSection extends StatelessWidget { onDateSelected: (date) { controller.nextBillingDate = date; }, - label: '다음 결제일', + label: AppLocalizations.of(context).nextBillingDate, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365 * 2)), primaryColor: baseColor, @@ -174,9 +175,9 @@ class DetailFormSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '카테고리', - style: TextStyle( + Text( + AppLocalizations.of(context).category, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.darkNavy, diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index c38f00e..5a3415e 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -3,7 +3,10 @@ import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../../models/subscription_model.dart'; import '../../controllers/detail_screen_controller.dart'; +import '../../providers/locale_provider.dart'; +import '../../services/currency_util.dart'; import '../website_icon.dart'; +import '../../l10n/app_localizations.dart'; /// 상세 화면 상단 헤더 섹션 /// 서비스 아이콘, 이름, 결제 정보를 표시합니다. @@ -134,7 +137,7 @@ class DetailHeaderSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - controller.serviceNameController.text, + controller.displayName ?? controller.serviceNameController.text, style: const TextStyle( fontSize: 28, fontWeight: FontWeight.w800, @@ -151,7 +154,8 @@ class DetailHeaderSection extends StatelessWidget { ), const SizedBox(height: 4), Text( - '${controller.billingCycle} 결제', + AppLocalizations.of(context).billingCyclePayment.replaceAll('@', + _getLocalizedBillingCycle(context, controller.billingCycle)), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, @@ -175,25 +179,28 @@ class DetailHeaderSection extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _InfoColumn( - label: '다음 결제일', - value: DateFormat('yyyy년 MM월 dd일') - .format(controller.nextBillingDate), + label: AppLocalizations.of(context).nextBillingDate, + value: AppLocalizations.of(context).formatDate(controller.nextBillingDate), ), - _InfoColumn( - label: '월 지출', - value: NumberFormat.currency( - locale: controller.currency == 'KRW' - ? 'ko_KR' - : 'en_US', - symbol: controller.currency == 'KRW' - ? '₩' - : '\$', - decimalDigits: - controller.currency == 'KRW' ? 0 : 2, - ).format(double.tryParse( - controller.monthlyCostController.text.replaceAll(',', '') - ) ?? 0), - alignment: CrossAxisAlignment.end, + FutureBuilder( + future: () async { + final locale = context.read().locale.languageCode; + final amount = double.tryParse( + controller.monthlyCostController.text.replaceAll(',', '') + ) ?? 0; + return CurrencyUtil.formatAmountWithLocale( + amount, + controller.currency, + locale, + ); + }(), + builder: (context, snapshot) { + return _InfoColumn( + label: AppLocalizations.of(context).monthlyExpense, + value: snapshot.data ?? '-', + alignment: CrossAxisAlignment.end, + ); + }, ), ], ), @@ -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; + } + } } /// 정보 표시 컬럼 diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart index dfa9431..183689e 100644 --- a/lib/widgets/detail/detail_url_section.dart +++ b/lib/widgets/detail/detail_url_section.dart @@ -3,6 +3,7 @@ import '../../controllers/detail_screen_controller.dart'; import '../../theme/app_colors.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/buttons/secondary_button.dart'; +import '../../l10n/app_localizations.dart'; /// 웹사이트 URL 섹션 /// 서비스 웹사이트 URL과 해지 관련 정보를 관리하는 섹션입니다. @@ -69,9 +70,9 @@ class DetailUrlSection extends StatelessWidget { ), ), const SizedBox(width: 12), - const Text( - '웹사이트 정보', - style: TextStyle( + Text( + AppLocalizations.of(context).websiteInfo, + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.darkNavy, @@ -85,8 +86,8 @@ class DetailUrlSection extends StatelessWidget { BaseTextField( controller: controller.websiteUrlController, focusNode: controller.websiteUrlFocus, - label: '웹사이트 URL', - hintText: 'https://example.com', + label: AppLocalizations.of(context).websiteUrl, + hintText: AppLocalizations.of(context).urlExample, keyboardType: TextInputType.url, prefixIcon: Icon( Icons.link_rounded, @@ -120,7 +121,7 @@ class DetailUrlSection extends StatelessWidget { ), const SizedBox(width: 8), Text( - '해지 안내', + AppLocalizations.of(context).cancelGuide, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -131,7 +132,7 @@ class DetailUrlSection extends StatelessWidget { ), const SizedBox(height: 8), Text( - '이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.', + AppLocalizations.of(context).cancelServiceGuide, style: TextStyle( fontSize: 14, color: AppColors.darkNavy, @@ -141,7 +142,7 @@ class DetailUrlSection extends StatelessWidget { ), const SizedBox(height: 12), TextLinkButton( - text: '해지 페이지로 이동', + text: AppLocalizations.of(context).goToCancelPage, icon: Icons.open_in_new_rounded, onPressed: controller.openCancellationPage, color: AppColors.warningColor, @@ -174,7 +175,7 @@ class DetailUrlSection extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - 'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다', + AppLocalizations.of(context).urlAutoMatchInfo, style: TextStyle( fontSize: 14, color: AppColors.darkNavy, diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index de53f8d..ddebf72 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'glassmorphism_card.dart'; import 'themed_text.dart'; import '../theme/app_colors.dart'; +import '../l10n/app_localizations.dart'; /// 구독이 없을 때 표시되는 빈 화면 위젯 /// @@ -74,15 +75,15 @@ class EmptyStateWidget extends StatelessWidget { }, ), const SizedBox(height: 32), - const ThemedText( - '등록된 구독이 없습니다', + ThemedText( + AppLocalizations.of(context).noSubscriptions, fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: -0.5, ), const SizedBox(height: 8), - const ThemedText( - '새로운 구독을 추가해보세요', + ThemedText( + AppLocalizations.of(context).addSubscriptionNow, fontSize: 16, opacity: 0.7, ), @@ -107,8 +108,8 @@ class EmptyStateWidget extends StatelessWidget { HapticFeedback.mediumImpact(); onAddPressed(); }, - child: const Text( - '구독 추가하기', + child: Text( + AppLocalizations.of(context).addSubscription, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 3eac300..bd76066 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../theme/app_colors.dart'; import 'glassmorphism_card.dart'; +import '../l10n/app_localizations.dart'; class FloatingNavigationBar extends StatefulWidget { final int selectedIndex; @@ -103,13 +104,13 @@ class _FloatingNavigationBarState extends State children: [ _NavigationItem( icon: Icons.home_rounded, - label: '홈', + label: AppLocalizations.of(context).home, isSelected: widget.selectedIndex == 0, onTap: () => _onItemTapped(0), ), _NavigationItem( icon: Icons.analytics_rounded, - label: '분석', + label: AppLocalizations.of(context).analysis, isSelected: widget.selectedIndex == 1, onTap: () => _onItemTapped(1), ), @@ -118,13 +119,13 @@ class _FloatingNavigationBarState extends State ), _NavigationItem( icon: Icons.qr_code_scanner_rounded, - label: 'SMS', + label: AppLocalizations.of(context).smsScanLabel, isSelected: widget.selectedIndex == 3, onTap: () => _onItemTapped(3), ), _NavigationItem( icon: Icons.settings_rounded, - label: '설정', + label: AppLocalizations.of(context).settings, isSelected: widget.selectedIndex == 4, onTap: () => _onItemTapped(4), ), diff --git a/lib/widgets/glassmorphic_app_bar.dart b/lib/widgets/glassmorphic_app_bar.dart index cb980ea..86f1aa6 100644 --- a/lib/widgets/glassmorphic_app_bar.dart +++ b/lib/widgets/glassmorphic_app_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'dart:ui'; import '../theme/app_colors.dart'; import 'themed_text.dart'; +import '../l10n/app_localizations.dart'; /// 글래스모피즘 효과가 적용된 통일된 앱바 class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -113,7 +114,7 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget Navigator.of(context).pop(); }, splashRadius: 24, - tooltip: '뒤로가기', + tooltip: AppLocalizations.of(context).back, color: ThemedText.getContrastColor(context), ); } @@ -221,7 +222,7 @@ class GlassmorphicSliverAppBar extends StatelessWidget { Navigator.of(context).pop(); }, splashRadius: 24, - tooltip: '뒤로가기', + tooltip: AppLocalizations.of(context).back, ) : null), actions: actions, diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 00c6b0c..8b2be6c 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -173,8 +173,23 @@ class _AnimatedGlassmorphismCardState extends State @override Widget build(BuildContext context) { + // onTap이 없으면 제스처 처리를 하지 않음 + if (widget.onTap == null) { + return GlassmorphismCard( + padding: widget.padding, + margin: widget.margin, + width: widget.width, + height: widget.height, + borderRadius: widget.borderRadius, + blur: widget.blur, + opacity: widget.opacity, + onTap: null, + child: widget.child, + ); + } + return GestureDetector( - behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지 + behavior: HitTestBehavior.opaque, onTapDown: _handleTapDown, onTapUp: (details) { _handleTapUp(details); diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index 5b0ee2e..c3dd6bf 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -8,6 +8,7 @@ import '../widgets/main_summary_card.dart'; import '../widgets/subscription_list_widget.dart'; import '../widgets/empty_state_widget.dart'; import '../theme/app_colors.dart'; +import '../l10n/app_localizations.dart'; class HomeContent extends StatelessWidget { final AnimationController fadeController; @@ -55,6 +56,7 @@ class HomeContent extends StatelessWidget { final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions( provider.subscriptions, categoryProvider, + context, ); return RefreshIndicator( @@ -103,7 +105,7 @@ class HomeContent extends StatelessWidget { ).animate(CurvedAnimation( parent: slideController, curve: Curves.easeOutCubic)), child: Text( - '나의 구독 서비스', + AppLocalizations.of(context).mySubscriptions, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: AppColors.darkNavy, ), @@ -118,7 +120,7 @@ class HomeContent extends StatelessWidget { child: Row( children: [ Text( - '${provider.subscriptions.length}개', + AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index 3a5427f..d27c104 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; +import '../providers/locale_provider.dart'; import '../services/currency_util.dart'; import '../theme/app_colors.dart'; import 'animated_wave_background.dart'; import 'glassmorphism_card.dart'; +import '../l10n/app_localizations.dart'; /// 메인 화면 상단에 표시되는 요약 카드 위젯 /// @@ -26,10 +29,12 @@ class MainScreenSummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { - final double monthlyCost = provider.totalMonthlyExpense; - final double yearlyCost = monthlyCost * 12; + // 언어 설정 가져오기 + final locale = context.watch().locale.languageCode; + final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); + final currencySymbol = CurrencyUtil.getCurrencySymbol(defaultCurrency); + final int totalSubscriptions = provider.subscriptions.length; - final double eventSavings = provider.totalEventSavings; final int activeEvents = provider.activeEventSubscriptions.length; return FadeTransition( @@ -83,7 +88,7 @@ class MainScreenSummaryCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '이번 달 총 구독 비용', + AppLocalizations.of(context).monthlyTotalSubscriptionCost, style: TextStyle( color: AppColors .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 @@ -91,88 +96,123 @@ class MainScreenSummaryCard extends StatelessWidget { fontWeight: FontWeight.w500, ), ), - FutureBuilder( - future: CurrencyUtil.getExchangeRateInfo(), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data!.isNotEmpty) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFE5F2FF), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: const Color(0xFFBFDBFE), - width: 1, + // 환율 정보 표시 (영어 사용자는 표시 안함) + if (locale != 'en') + FutureBuilder( + future: CurrencyUtil.getExchangeRateInfoForLocale(locale), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, ), - ), - child: Text( - snapshot.data!, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), + decoration: BoxDecoration( + color: const Color(0xFFE5F2FF), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFFBFDBFE), + width: 1, + ), ), - ), - ); - } - return const SizedBox.shrink(); - }, - ), + child: Text( + AppLocalizations.of(context).exchangeRateDisplay.replaceAll('@', snapshot.data!), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), ], ), const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - NumberFormat.currency( - locale: 'ko_KR', - symbol: '', - decimalDigits: 0, - ).format(monthlyCost), - style: const TextStyle( - color: AppColors - .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 32, - fontWeight: FontWeight.bold, - letterSpacing: -1, - ), - ), - const SizedBox(width: 4), - Text( - '원', - style: TextStyle( - color: AppColors - .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], + // 월별 총 비용 표시 (언어별 기본 통화) + FutureBuilder( + 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, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + NumberFormat.currency( + locale: defaultCurrency == 'KRW' ? 'ko_KR' : + defaultCurrency == 'JPY' ? 'ja_JP' : + defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', + symbol: '', + decimalDigits: decimals, + ).format(monthlyCost), + style: const TextStyle( + color: AppColors.darkNavy, + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: -1, + ), + ), + const SizedBox(width: 4), + Text( + currencySymbol, + style: const TextStyle( + color: AppColors.darkNavy, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + }, ), const SizedBox(height: 16), - Row( - children: [ - _buildInfoBox( - context, - title: '예상 연간 구독 비용', - value: '${NumberFormat.currency( - locale: 'ko_KR', - symbol: '', - decimalDigits: 0, - ).format(yearlyCost)}원', - ), - const SizedBox(width: 16), - _buildInfoBox( - context, - title: '총 구독 서비스', - value: '$totalSubscriptions개', - ), - ], + // 연간 비용 및 총 구독 수 표시 + FutureBuilder( + 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: [ + _buildInfoBox( + context, + title: AppLocalizations.of(context).estimatedAnnualCost, + value: '${NumberFormat.currency( + locale: defaultCurrency == 'KRW' ? 'ko_KR' : + defaultCurrency == 'JPY' ? 'ja_JP' : + defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', + symbol: currencySymbol, + decimalDigits: decimals, + ).format(yearlyCost)}', + ), + const SizedBox(width: 16), + _buildInfoBox( + context, + title: AppLocalizations.of(context).totalSubscriptionServices, + value: '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}', + ), + ], + ); + }, ), // 이벤트 절약액 표시 if (activeEvents > 0) ...[ @@ -215,7 +255,7 @@ class MainScreenSummaryCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '이벤트 할인 중', + AppLocalizations.of(context).eventDiscountActive, style: TextStyle( color: AppColors .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 @@ -224,31 +264,46 @@ class MainScreenSummaryCard extends StatelessWidget { ), ), const SizedBox(height: 2), - Row( - children: [ - Text( - NumberFormat.currency( - locale: 'ko_KR', - symbol: '₩', - decimalDigits: 0, - ).format(eventSavings), - style: const TextStyle( - color: AppColors - .primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조 - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - Text( - ' 절약 ($activeEvents개)', - style: TextStyle( - color: AppColors - .navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], + // 이벤트 절약액 표시 (언어별 기본 통화) + FutureBuilder( + 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: [ + Text( + NumberFormat.currency( + locale: defaultCurrency == 'KRW' ? 'ko_KR' : + defaultCurrency == 'JPY' ? 'ja_JP' : + defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', + symbol: currencySymbol, + decimalDigits: decimals, + ).format(eventSavings), + style: const TextStyle( + color: AppColors.primaryColor, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + Text( + ' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})', + style: const TextStyle( + color: AppColors.navyGray, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + }, ), ], ), diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index a4fe8be..2728d6f 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -3,10 +3,14 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../models/subscription_model.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 'app_navigator.dart'; import '../theme/app_colors.dart'; import 'glassmorphism_card.dart'; +import '../l10n/app_localizations.dart'; class SubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; @@ -26,6 +30,7 @@ class _SubscriptionCardState extends State with SingleTickerProviderStateMixin { late AnimationController _hoverController; bool _isHovering = false; + String? _displayName; @override void initState() { @@ -34,9 +39,36 @@ class _SubscriptionCardState extends State vsync: this, duration: const Duration(milliseconds: 200), ); + _loadDisplayName(); + } + + Future _loadDisplayName() async { + if (!mounted) return; + + final localeProvider = context.read(); + 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 void dispose() { _hoverController.dispose(); @@ -66,20 +98,20 @@ class _SubscriptionCardState extends State // 오늘이 결제일인 경우 if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) { - return '오늘 결제 예정'; + return AppLocalizations.of(context).paymentDueToday; } // 미래 날짜인 경우 남은 일수 계산 if (dateOnlyBilling.isAfter(dateOnlyNow)) { final difference = dateOnlyBilling.difference(dateOnlyNow).inDays; - return '$difference일 후 결제 예정'; + return AppLocalizations.of(context).paymentDueInDays(difference); } // 과거 날짜인 경우, 다음 결제일 계산 final billingCycle = widget.subscription.billingCycle; // 월간 구독인 경우 - if (billingCycle == '월간') { + if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'monthly') { // 결제일에 해당하는 날짜 가져오기 int day = nextBillingDate.day; int nextMonth = now.month; @@ -109,12 +141,12 @@ class _SubscriptionCardState extends State final nextDate = DateTime(nextYear, nextMonth, day); final days = nextDate.difference(dateOnlyNow).inDays; - if (days == 0) return '오늘 결제 예정'; - return '$days일 후 결제 예정'; + if (days == 0) return AppLocalizations.of(context).paymentDueToday; + return AppLocalizations.of(context).paymentDueInDays(days); } // 연간 구독인 경우 - if (billingCycle == '연간') { + if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'yearly') { // 결제일에 해당하는 날짜와 월 가져오기 int day = nextBillingDate.day; int month = nextBillingDate.month; @@ -143,18 +175,18 @@ class _SubscriptionCardState extends State final nextYearDate = DateTime(year, month, day); final days = nextYearDate.difference(dateOnlyNow).inDays; - if (days == 0) return '오늘 결제 예정'; - return '$days일 후 결제 예정'; + if (days == 0) return AppLocalizations.of(context).paymentDueToday; + return AppLocalizations.of(context).paymentDueInDays(days); } else { final days = thisYearDate.difference(dateOnlyNow).inDays; - if (days == 0) return '오늘 결제 예정'; - return '$days일 후 결제 예정'; + if (days == 0) return AppLocalizations.of(context).paymentDueToday; + return AppLocalizations.of(context).paymentDueInDays(days); } } // 주간 구독인 경우 - if (billingCycle == '주간') { + if (SubscriptionModel.normalizeBillingCycle(billingCycle) == 'weekly') { // 결제 요일 가져오기 final billingWeekday = nextBillingDate.weekday; // 현재 요일 @@ -171,20 +203,20 @@ class _SubscriptionCardState extends State daysUntilNext = 7; // 다음 주 같은 요일 } - if (daysUntilNext == 0) return '오늘 결제 예정'; - return '$daysUntilNext일 후 결제 예정'; + if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday; + return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); } // 기본값 - 예상할 수 없는 경우 - return '결제일 정보 필요'; + return AppLocalizations.of(context).paymentInfoNeeded; } // 결제일이 가까운지 확인 (7일 이내) bool _isNearBilling() { 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); if (match != null) { final days = int.parse(match.group(1) ?? '0'); @@ -222,9 +254,41 @@ class _SubscriptionCardState extends State } } + // 가격 포맷팅 함수 (언어별 통화) + Future _getFormattedPrice() async { + final locale = context.read().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 Widget build(BuildContext context) { + // LocaleProvider를 watch하여 언어 변경시 자동 업데이트 + final localeProvider = context.watch(); + + // 언어가 변경되면 displayName 다시 로드 + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadDisplayName(); + }); + final isNearBilling = _isNearBilling(); return Hero( @@ -238,6 +302,7 @@ class _SubscriptionCardState extends State blur: _isHovering ? 15 : 10, width: double.infinity, // 전체 너비를 차지하도록 설정 onTap: widget.onTap ?? () async { + print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}'); await AppNavigator.toDetail(context, widget.subscription); }, child: Column( @@ -290,7 +355,7 @@ class _SubscriptionCardState extends State // 서비스명 Flexible( child: Text( - widget.subscription.serviceName, + _displayName ?? widget.subscription.serviceName, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 18, @@ -322,18 +387,18 @@ class _SubscriptionCardState extends State borderRadius: BorderRadius.circular(12), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( + const Icon( Icons.local_offer_rounded, size: 11, color: AppColors.pureWhite, ), - SizedBox(width: 3), + const SizedBox(width: 3), Text( - '이벤트', - style: TextStyle( + AppLocalizations.of(context).event, + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.pureWhite, @@ -361,7 +426,7 @@ class _SubscriptionCardState extends State ), ), child: Text( - widget.subscription.billingCycle, + AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle), style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, @@ -382,57 +447,51 @@ class _SubscriptionCardState extends State MainAxisAlignment.spaceBetween, children: [ // 가격 표시 (이벤트 가격 반영) - Row( - children: [ - // 이벤트 중인 경우 원래 가격을 취소선으로 표시 - if (widget.subscription.isCurrentlyInEvent) ...[ - Text( - widget.subscription.currency == 'USD' - ? 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( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - decoration: TextDecoration.lineThrough, + // 가격 표시 (언어별 통화) + FutureBuilder( + future: _getFormattedPrice(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + + if (widget.subscription.isCurrentlyInEvent && snapshot.data!.contains('|')) { + final prices = snapshot.data!.split('|'); + return Row( + children: [ + Text( + prices[0], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.navyGray, + decoration: TextDecoration.lineThrough, + ), + ), + const SizedBox(width: 8), + Text( + prices[1], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFFFF6B6B), + ), + ), + ], + ); + } else { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: widget.subscription.isCurrentlyInEvent + ? const Color(0xFFFF6B6B) + : AppColors.primaryColor, ), - ), - const SizedBox(width: 8), - ], - // 현재 가격 (이벤트 또는 정상 가격) - Text( - widget.subscription.currency == 'USD' - ? NumberFormat.currency( - locale: 'en_US', - symbol: '\$', - decimalDigits: 2, - ).format(widget - .subscription.currentPrice) - : NumberFormat.currency( - locale: 'ko_KR', - symbol: '₩', - decimalDigits: 0, - ).format(widget - .subscription.currentPrice), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: widget.subscription.isCurrentlyInEvent - ? const Color(0xFFFF6B6B) - : AppColors.primaryColor, - ), - ), - ], + ); + } + }, ), // 결제 예정일 정보 @@ -505,23 +564,25 @@ class _SubscriptionCardState extends State color: Color(0xFFFF6B6B), ), const SizedBox(width: 4), - Text( - widget.subscription.currency == 'USD' - ? '${NumberFormat.currency( - locale: 'en_US', - symbol: '\$', - decimalDigits: 2, - ).format(widget.subscription.eventSavings)} 절약' - : '${NumberFormat.currency( - locale: 'ko_KR', - symbol: '₩', - decimalDigits: 0, - ).format(widget.subscription.eventSavings)} 절약', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFFFF6B6B), + // 이벤트 절약액 표시 (언어별 통화) + FutureBuilder( + future: CurrencyUtil.formatEventSavingsWithLocale( + widget.subscription, + localeProvider.locale.languageCode, ), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return Text( + '${snapshot.data!} ${AppLocalizations.of(context).saving}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFFF6B6B), + ), + ); + }, ), ], ), @@ -530,7 +591,7 @@ class _SubscriptionCardState extends State // 이벤트 종료일까지 남은 일수 if (widget.subscription.eventEndDate != null) ...[ Text( - '${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음', + AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays), style: const TextStyle( fontSize: 11, color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index 9c9c8b4..65e3bd9 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -6,8 +6,12 @@ import '../widgets/staggered_list_animation.dart'; import '../widgets/app_navigator.dart'; import 'package:provider/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 './common/snackbar/app_snackbar.dart'; +import '../l10n/app_localizations.dart'; /// 카테고리별로 구독 목록을 표시하는 위젯 class SubscriptionListWidget extends StatelessWidget { @@ -39,11 +43,17 @@ class SubscriptionListWidget extends StatelessWidget { // 카테고리 헤더 Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: CategoryHeaderWidget( - categoryName: category, - subscriptionCount: subscriptions.length, - totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), - totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), + child: Consumer( + builder: (context, categoryProvider, child) { + return CategoryHeaderWidget( + categoryName: categoryProvider.getLocalizedCategoryName(context, category), + subscriptionCount: subscriptions.length, + totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), + 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]); }, onDelete: () async { + // 현재 로케일에 맞는 서비스명 가져오기 + final localeProvider = Provider.of( + context, + listen: false, + ); + final locale = localeProvider.locale.languageCode; + final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( + serviceName: subscriptions[subIndex].serviceName, + locale: locale, + ); + // 삭제 확인 다이얼로그 표시 final shouldDelete = await DeleteConfirmationDialog.show( context: context, - serviceName: subscriptions[subIndex].serviceName, + serviceName: displayName, ); if (shouldDelete && context.mounted) { @@ -108,7 +129,7 @@ class SubscriptionListWidget extends StatelessWidget { if (context.mounted) { AppSnackBar.showError( context: context, - message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.', + message: AppLocalizations.of(context).subscriptionDeleted(displayName), icon: Icons.delete_forever_rounded, ); } diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart index 0e1b314..f674a1b 100644 --- a/lib/widgets/swipeable_subscription_card.dart +++ b/lib/widgets/swipeable_subscription_card.dart @@ -122,26 +122,17 @@ class _SwipeableSubscriptionCardState extends State } void _handlePanEnd(DragEndDetails details) { - final duration = DateTime.now().difference(_startTime!); final velocity = details.velocity.pixelsPerSecond.dx; - // 탭/스와이프 처리 분기 - - // 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리 - if (_isValidTap && - duration.inMilliseconds < _tapDurationMs && - _currentOffset.abs() < _tapTolerance) { - _processTap(); - return; - } - - // 스와이프 처리 + // 스와이프 처리만 수행 (탭은 SubscriptionCard에서 처리) _processSwipe(velocity); } // 헬퍼 메서드 void _processTap() { + print('[SwipeableSubscriptionCard] _processTap 호출됨'); if (widget.onTap != null) { + print('[SwipeableSubscriptionCard] onTap 콜백 실행'); widget.onTap!(); } _animateToOffset(0); @@ -151,6 +142,12 @@ class _SwipeableSubscriptionCardState extends State final extent = _currentOffset.abs(); final deleteThreshold = _cardWidth * _deleteThresholdPercent; + // 아주 작은 움직임은 무시하고 원위치로 복귀 + if (extent < _tapTolerance) { + _animateToOffset(0); + return; + } + if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) { // 삭제 실행 if (widget.onDelete != null) { @@ -261,7 +258,7 @@ class _SwipeableSubscriptionCardState extends State angle: _currentOffset / 2000, child: SubscriptionCard( subscription: widget.subscription, - onTap: widget.onTap, + onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 ), ), ), @@ -279,6 +276,7 @@ class _SwipeableSubscriptionCardState extends State onPanStart: _handlePanStart, onPanUpdate: _handlePanUpdate, onPanEnd: _handlePanEnd, + // onTap 제거 - SubscriptionCard의 AnimatedGlassmorphismCard에서 처리하도록 함 child: _buildCard(), ), ],