diff --git a/assets/data/subscription_services.json b/assets/data/subscription_services.json new file mode 100644 index 0000000..2c113db --- /dev/null +++ b/assets/data/subscription_services.json @@ -0,0 +1,736 @@ +{ + "categories": { + "music": { + "nameKr": "음악 스트리밍", + "nameEn": "Music Streaming", + "services": { + "spotify": { + "names": ["spotify", "스포티파이"], + "urls": { + "kr": "https://www.spotify.com/kr/", + "en": "https://www.spotify.com" + }, + "cancellationUrls": { + "kr": "https://support.spotify.com/kr/article/premium-구독-취소/", + "en": "https://support.spotify.com/us/article/cancel-premium-subscription/" + }, + "domains": ["spotify"] + }, + "apple_music": { + "names": ["apple music", "애플 뮤직", "애플뮤직"], + "urls": { + "kr": "https://www.apple.com/kr/apple-music/", + "en": "https://music.apple.com" + }, + "cancellationUrls": { + "kr": "https://support.apple.com/ko-kr/HT204939", + "en": "https://support.apple.com/en-us/HT204939" + }, + "domains": ["apple", "music.apple"] + }, + "youtube_music": { + "names": ["youtube music", "유튜브 뮤직", "유튜브뮤직"], + "urls": { + "kr": "https://music.youtube.com", + "en": "https://music.youtube.com" + }, + "cancellationUrls": { + "kr": "https://support.google.com/youtubemusic/answer/6313533?hl=ko", + "en": "https://support.google.com/youtubemusic/answer/6313533?hl=en" + }, + "domains": ["youtube", "music.youtube"] + }, + "melon": { + "names": ["melon", "멜론"], + "urls": { + "kr": "https://www.melon.com", + "en": "https://www.melon.com" + }, + "cancellationUrls": { + "kr": "https://help.melon.com/customer/faq/faq_view.htm?faqSeq=3701", + "en": null + }, + "domains": ["melon"] + }, + "genie": { + "names": ["genie", "지니", "genie music"], + "urls": { + "kr": "https://www.genie.co.kr", + "en": "https://www.genie.co.kr" + }, + "cancellationUrls": { + "kr": "https://help.genie.co.kr/customer/faq/faq_view.htm?faqSeq=1132", + "en": null + }, + "domains": ["genie"] + }, + "flo": { + "names": ["flo", "플로"], + "urls": { + "kr": "https://www.music-flo.com", + "en": "https://www.music-flo.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["music-flo", "flo"] + }, + "bugs": { + "names": ["bugs", "벅스"], + "urls": { + "kr": "https://music.bugs.co.kr", + "en": "https://music.bugs.co.kr" + }, + "cancellationUrls": { + "kr": "https://help.bugs.co.kr/faq/faqDetail?faqId=1000000000000039", + "en": null + }, + "domains": ["bugs"] + }, + "vibe": { + "names": ["vibe", "바이브"], + "urls": { + "kr": "https://vibe.naver.com", + "en": "https://vibe.naver.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["vibe"] + }, + "tidal": { + "names": ["tidal", "타이달"], + "urls": { + "kr": "https://tidal.com/kr/", + "en": "https://tidal.com" + }, + "cancellationUrls": { + "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"] + } + } + }, + "ott": { + "nameKr": "OTT 서비스", + "nameEn": "OTT Services", + "services": { + "netflix": { + "names": ["netflix", "넷플릭스"], + "urls": { + "kr": "https://www.netflix.com/kr/", + "en": "https://www.netflix.com" + }, + "cancellationUrls": { + "kr": "https://help.netflix.com/ko/node/407", + "en": "https://help.netflix.com/en/node/407" + }, + "domains": ["netflix"] + }, + "disney_plus": { + "names": ["disney+", "디즈니플러스", "disney plus"], + "urls": { + "kr": "https://www.disneyplus.com/kr", + "en": "https://www.disneyplus.com" + }, + "cancellationUrls": { + "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"] + }, + "apple_tv": { + "names": ["apple tv+", "애플 티비플러스", "애플티비"], + "urls": { + "kr": "https://tv.apple.com/kr/", + "en": "https://tv.apple.com" + }, + "cancellationUrls": { + "kr": "https://support.apple.com/ko-kr/HT207043", + "en": "https://support.apple.com/en-us/HT207043" + }, + "domains": ["tv.apple"] + }, + "youtube_premium": { + "names": ["youtube premium", "유튜브 프리미엄"], + "urls": { + "kr": "https://www.youtube.com/premium?gl=KR", + "en": "https://www.youtube.com/premium" + }, + "cancellationUrls": { + "kr": "https://support.google.com/youtube/answer/6306271?hl=ko", + "en": "https://support.google.com/youtube/answer/6306271?hl=en" + }, + "domains": ["youtube"] + }, + "tving": { + "names": ["tving", "티빙"], + "urls": { + "kr": "https://www.tving.com", + "en": "https://www.tving.com" + }, + "cancellationUrls": { + "kr": "https://www.tving.com/my/cancelMembership", + "en": null + }, + "domains": ["tving"] + }, + "wavve": { + "names": ["wavve", "웨이브"], + "urls": { + "kr": "https://www.wavve.com", + "en": "https://www.wavve.com" + }, + "cancellationUrls": { + "kr": "https://www.wavve.com/my", + "en": null + }, + "domains": ["wavve"] + }, + "watcha": { + "names": ["watcha", "왓챠"], + "urls": { + "kr": "https://watcha.com", + "en": "https://watcha.com" + }, + "cancellationUrls": { + "kr": "https://watcha.com/settings/payment", + "en": null + }, + "domains": ["watcha"] + }, + "coupang_play": { + "names": ["coupang play", "쿠팡 플레이", "쿠팡플레이"], + "urls": { + "kr": "https://www.coupangplay.com", + "en": "https://www.coupangplay.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["coupangplay", "play.coupangplay"] + }, + "amazon_prime": { + "names": ["amazon prime", "아마존 프라임", "prime video"], + "urls": { + "kr": "https://www.primevideo.com", + "en": "https://www.primevideo.com" + }, + "cancellationUrls": { + "kr": "https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M", + "en": "https://www.amazon.com/gp/primecentral/managemembership" + }, + "domains": ["primevideo", "amazon"] + }, + "hulu": { + "names": ["hulu", "훌루"], + "urls": { + "kr": "https://www.hulu.com", + "en": "https://www.hulu.com" + }, + "cancellationUrls": { + "kr": "https://help.hulu.com/hc/ko/articles/360001164823", + "en": "https://help.hulu.com/s/article/how-do-i-cancel" + }, + "domains": ["hulu"] + } + } + }, + "storage": { + "nameKr": "저장/클라우드", + "nameEn": "Storage/Cloud", + "services": { + "google_drive": { + "names": ["google drive", "구글 드라이브", "구글드라이브"], + "urls": { + "kr": "https://www.google.com/drive/", + "en": "https://www.google.com/drive/" + }, + "cancellationUrls": { + "kr": "https://support.google.com/drive/answer/2375082?hl=ko", + "en": "https://support.google.com/drive/answer/2375082?hl=en" + }, + "domains": ["drive.google", "google"] + }, + "dropbox": { + "names": ["dropbox", "드롭박스"], + "urls": { + "kr": "https://www.dropbox.com", + "en": "https://www.dropbox.com" + }, + "cancellationUrls": { + "kr": "https://help.dropbox.com/plans/downgrade-dropbox-individual-plans", + "en": "https://help.dropbox.com/plans/downgrade-dropbox-individual-plans" + }, + "domains": ["dropbox"] + }, + "onedrive": { + "names": ["onedrive", "원드라이브", "microsoft onedrive"], + "urls": { + "kr": "https://www.onedrive.com", + "en": "https://www.onedrive.com" + }, + "cancellationUrls": { + "kr": null, + "en": "https://support.microsoft.com/en-us/office/cancel-your-microsoft-365-subscription-" + }, + "domains": ["onedrive"] + }, + "icloud": { + "names": ["icloud", "아이클라우드"], + "urls": { + "kr": "https://www.icloud.com", + "en": "https://www.icloud.com" + }, + "cancellationUrls": { + "kr": "https://support.apple.com/ko-kr/HT207594", + "en": "https://support.apple.com/en-us/HT207594" + }, + "domains": ["icloud"] + }, + "google_one": { + "names": ["google one", "구글 원"], + "urls": { + "kr": "https://one.google.com", + "en": "https://one.google.com" + }, + "cancellationUrls": { + "kr": "https://support.google.com/googleone/answer/9140429", + "en": "https://support.google.com/googleone/answer/9140429" + }, + "domains": ["one.google"] + }, + "naver_mybox": { + "names": ["naver mybox", "네이버 마이박스", "마이박스"], + "urls": { + "kr": "https://mybox.naver.com", + "en": null + }, + "cancellationUrls": { + "kr": "https://help.naver.com/service/5638/contents/10041?osType=PC", + "en": null + }, + "domains": ["mybox.naver"] + } + } + }, + "ai": { + "nameKr": "AI 서비스", + "nameEn": "AI Services", + "services": { + "chatgpt": { + "names": ["chatgpt", "챗GPT", "chatgpt plus"], + "urls": { + "kr": "https://chat.openai.com", + "en": "https://chat.openai.com" + }, + "cancellationUrls": { + "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"] + }, + "claude": { + "names": ["claude", "클로드", "claude pro"], + "urls": { + "kr": "https://claude.ai", + "en": "https://claude.ai" + }, + "cancellationUrls": { + "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"] + }, + "midjourney": { + "names": ["midjourney", "미드저니"], + "urls": { + "kr": "https://www.midjourney.com", + "en": "https://www.midjourney.com" + }, + "cancellationUrls": { + "kr": "https://docs.midjourney.com/docs/manage-subscription", + "en": "https://docs.midjourney.com/docs/manage-subscription" + }, + "domains": ["midjourney"] + }, + "perplexity": { + "names": ["perplexity", "퍼플렉시티", "perplexity pro"], + "urls": { + "kr": "https://www.perplexity.ai", + "en": "https://www.perplexity.ai" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["perplexity"] + }, + "copilot": { + "names": ["copilot", "코파일럿", "github copilot"], + "urls": { + "kr": "https://copilot.microsoft.com", + "en": "https://copilot.microsoft.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["copilot.microsoft"] + }, + "gemini": { + "names": ["gemini", "제미니", "google gemini"], + "urls": { + "kr": "https://gemini.google.com", + "en": "https://gemini.google.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["gemini.google"] + } + } + }, + "programming": { + "nameKr": "프로그래밍/개발", + "nameEn": "Programming/Development", + "services": { + "github": { + "names": ["github", "깃허브"], + "urls": { + "kr": "https://github.com", + "en": "https://github.com" + }, + "cancellationUrls": { + "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"] + }, + "cursor": { + "names": ["cursor", "커서"], + "urls": { + "kr": "https://cursor.com", + "en": "https://cursor.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["cursor"] + }, + "jetbrains": { + "names": ["jetbrains", "제트브레인스", "intellij"], + "urls": { + "kr": "https://www.jetbrains.com", + "en": "https://www.jetbrains.com" + }, + "cancellationUrls": { + "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"] + }, + "aws": { + "names": ["aws", "아마존 웹서비스", "amazon web services"], + "urls": { + "kr": "https://aws.amazon.com", + "en": "https://aws.amazon.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["aws.amazon", "aws"] + }, + "azure": { + "names": ["azure", "애저", "microsoft azure"], + "urls": { + "kr": "https://azure.microsoft.com", + "en": "https://azure.microsoft.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["azure.microsoft"] + }, + "google_cloud": { + "names": ["google cloud", "구글 클라우드", "gcp"], + "urls": { + "kr": "https://cloud.google.com", + "en": "https://cloud.google.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["cloud.google"] + } + } + }, + "office": { + "nameKr": "오피스/협업툴", + "nameEn": "Office/Collaboration", + "services": { + "microsoft_365": { + "names": ["microsoft 365", "마이크로소프트 365", "office 365", "오피스 365"], + "urls": { + "kr": "https://www.microsoft.com/microsoft-365", + "en": "https://www.microsoft.com/microsoft-365" + }, + "cancellationUrls": { + "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"] + }, + "google_workspace": { + "names": ["google workspace", "구글 워크스페이스"], + "urls": { + "kr": "https://workspace.google.com", + "en": "https://workspace.google.com" + }, + "cancellationUrls": { + "kr": null, + "en": "https://support.google.com/a/answer/1257646?hl=en" + }, + "domains": ["workspace.google"] + }, + "slack": { + "names": ["slack", "슬랙"], + "urls": { + "kr": "https://slack.com", + "en": "https://slack.com" + }, + "cancellationUrls": { + "kr": "https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription", + "en": "https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription" + }, + "domains": ["slack"] + }, + "notion": { + "names": ["notion", "노션"], + "urls": { + "kr": "https://www.notion.so", + "en": "https://www.notion.so" + }, + "cancellationUrls": { + "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"] + }, + "figma": { + "names": ["figma", "피그마"], + "urls": { + "kr": "https://www.figma.com", + "en": "https://www.figma.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["figma"] + }, + "adobe_creative_cloud": { + "names": ["adobe creative cloud", "어도비 크리에이티브 클라우드", "adobe cc"], + "urls": { + "kr": "https://www.adobe.com/creativecloud.html", + "en": "https://www.adobe.com/creativecloud.html" + }, + "cancellationUrls": { + "kr": "https://helpx.adobe.com/manage-account/using/cancel-subscription.html", + "en": "https://helpx.adobe.com/manage-account/using/cancel-subscription.html" + }, + "domains": ["adobe"] + } + } + }, + "lifestyle": { + "nameKr": "생활/라이프스타일", + "nameEn": "Lifestyle", + "services": { + "naver_plus": { + "names": ["네이버 플러스", "naver plus"], + "urls": { + "kr": "https://plus.naver.com", + "en": null + }, + "cancellationUrls": { + "kr": "https://help.naver.com/service/5638/contents/10041?osType=PC", + "en": null + }, + "domains": ["plus.naver"] + }, + "kakao_subscribe": { + "names": ["카카오 구독", "kakao subscribe"], + "urls": { + "kr": "https://subscribe.kakao.com", + "en": null + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["subscribe.kakao"] + }, + "coupang_wow": { + "names": ["쿠팡 와우", "coupang wow", "쿠팡와우"], + "urls": { + "kr": "https://www.coupang.com/np/coupangplus", + "en": null + }, + "cancellationUrls": { + "kr": "https://help.coupang.com/cc/ko/contents/faq/1000002013", + "en": null + }, + "domains": ["coupang"] + }, + "kurly": { + "names": ["마켓컬리", "kurly", "컬리"], + "urls": { + "kr": "https://www.kurly.com", + "en": null + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["kurly"] + } + } + }, + "shopping": { + "nameKr": "쇼핑/이커머스", + "nameEn": "Shopping/E-commerce", + "services": { + "amazon_prime": { + "names": ["amazon prime", "아마존 프라임"], + "urls": { + "kr": "https://www.amazon.co.kr/prime", + "en": "https://www.amazon.com/prime" + }, + "cancellationUrls": { + "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"] + }, + "walmart_plus": { + "names": ["walmart+", "월마트플러스"], + "urls": { + "kr": null, + "en": "https://www.walmart.com/plus" + }, + "cancellationUrls": { + "kr": null, + "en": "https://www.walmart.com/help/article/how-do-i-cancel-my-walmart-membership/2c1f2b2c9e6e4e3c9c8d9e5e" + }, + "domains": ["walmart"] + } + } + }, + "gaming": { + "nameKr": "게임", + "nameEn": "Gaming", + "services": { + "nintendo_switch_online": { + "names": ["nintendo switch online", "닌텐도 스위치 온라인"], + "urls": { + "kr": "https://www.nintendo.com/switch/online-service", + "en": "https://www.nintendo.com/switch/online-service" + }, + "cancellationUrls": { + "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"] + }, + "playstation_plus": { + "names": ["playstation plus", "플레이스테이션 플러스", "ps plus"], + "urls": { + "kr": "https://www.playstation.com/ps-plus", + "en": "https://www.playstation.com/ps-plus" + }, + "cancellationUrls": { + "kr": "https://www.playstation.com/support/subscriptions/cancel-playstation-plus/", + "en": "https://www.playstation.com/support/subscriptions/cancel-playstation-plus/" + }, + "domains": ["playstation"] + }, + "xbox_game_pass": { + "names": ["xbox game pass", "엑스박스 게임 패스"], + "urls": { + "kr": "https://www.xbox.com/xbox-game-pass", + "en": "https://www.xbox.com/xbox-game-pass" + }, + "cancellationUrls": { + "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"] + }, + "steam": { + "names": ["steam", "스팀"], + "urls": { + "kr": "https://store.steampowered.com", + "en": "https://store.steampowered.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["steampowered", "steam"] + } + } + }, + "telecom": { + "nameKr": "통신/인터넷/TV", + "nameEn": "Telecom/Internet/TV", + "services": { + "skt": { + "names": ["skt", "sk텔레콤"], + "urls": { + "kr": "https://www.sktelecom.com", + "en": "https://www.sktelecom.com" + }, + "cancellationUrls": { + "kr": "https://www.sktelecom.com/support/cancel.do", + "en": null + }, + "domains": ["sktelecom"] + }, + "kt": { + "names": ["kt"], + "urls": { + "kr": "https://www.kt.com", + "en": "https://www.kt.com" + }, + "cancellationUrls": { + "kr": null, + "en": null + }, + "domains": ["kt"] + }, + "lguplus": { + "names": ["lgu+", "lg유플러스", "lg u+"], + "urls": { + "kr": "https://www.lguplus.com", + "en": "https://www.lguplus.com" + }, + "cancellationUrls": { + "kr": "https://www.lguplus.com/support/faq/faqDetail?faqId=FAQ00000000000002720", + "en": null + }, + "domains": ["lguplus"] + } + } + } + } +} \ No newline at end of file diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart index f0c87a3..49e05a1 100644 --- a/lib/controllers/add_subscription_controller.dart +++ b/lib/controllers/add_subscription_controller.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../providers/subscription_provider.dart'; @@ -73,6 +73,9 @@ class AddSubscriptionController { // 서비스명 컨트롤러에 리스너 추가 serviceNameController.addListener(onServiceNameChanged); + // 웹사이트 URL 컨트롤러에 리스너 추가 + websiteUrlController.addListener(onWebsiteUrlChanged); + // 애니메이션 컨트롤러 초기화 animationController = AnimationController( vsync: vsync, @@ -133,6 +136,52 @@ class AddSubscriptionController { void onServiceNameChanged() { autoSelectCategory(); } + + /// 웹사이트 URL 변경시 호출 + void onWebsiteUrlChanged() async { + final url = websiteUrlController.text.trim(); + + // URL이 비어있거나 너무 짧으면 무시 + if (url.isEmpty || url.length < 5) return; + + // 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음 + if (serviceNameController.text.isNotEmpty) return; + + try { + // URL로 서비스 정보 찾기 + final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url); + + if (serviceInfo != null && context.mounted) { + // 서비스명 자동 입력 + serviceNameController.text = serviceInfo.serviceName; + + // 카테고리 자동 선택 + final categoryProvider = Provider.of(context, listen: false); + final categories = categoryProvider.categories; + + // 카테고리 ID로 매칭 + final matchedCategory = categories.firstWhere( + (cat) => cat.name == serviceInfo.categoryNameKr || + cat.name == serviceInfo.categoryNameEn, + orElse: () => categories.first, + ); + + selectedCategoryId = matchedCategory.id; + + // 스낵바로 알림 + if (context.mounted) { + AppSnackBar.showSuccess( + context: context, + message: '${serviceInfo.serviceName} 서비스가 자동으로 인식되었습니다.', + ); + } + } + } catch (e) { + if (kDebugMode) { + print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e'); + } + } + } /// 카테고리 자동 선택 void autoSelectCategory() { @@ -254,8 +303,42 @@ class AddSubscriptionController { } final subscription = subscriptions.first; + + // SMS에서 서비스 정보 추출 시도 + ServiceInfo? serviceInfo; + final smsContent = subscription['smsContent'] ?? ''; + + if (smsContent.isNotEmpty) { + try { + serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); + } catch (e) { + if (kDebugMode) { + print('AddSubscriptionController: SMS 서비스 추출 실패 - $e'); + } + } + } + setState(() { - serviceNameController.text = subscription['serviceName'] ?? ''; + // 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용 + if (serviceInfo != null) { + serviceNameController.text = serviceInfo.serviceName; + websiteUrlController.text = serviceInfo.serviceUrl ?? ''; + + // 카테고리 자동 선택 + final categoryProvider = Provider.of(context, listen: false); + final categories = categoryProvider.categories; + + final matchedCategory = categories.firstWhere( + (cat) => cat.name == serviceInfo!.categoryNameKr || + cat.name == serviceInfo.categoryNameEn, + orElse: () => categories.first, + ); + + selectedCategoryId = matchedCategory.id; + } else { + // 기존 로직 사용 + serviceNameController.text = subscription['serviceName'] ?? ''; + } // 비용 처리 및 통화 단위 자동 감지 final costValue = subscription['monthlyCost']?.toString() ?? ''; @@ -289,12 +372,13 @@ class AddSubscriptionController { ? DateTime.parse(subscription['nextBillingDate']) : DateTime.now(); - // 서비스명이 있으면 URL 자동 매칭 시도 - if (subscription['serviceName'] != null && + // 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도 + if (serviceInfo == null && + subscription['serviceName'] != null && subscription['serviceName'].isNotEmpty) { final suggestedUrl = SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); - if (suggestedUrl != null) { + if (suggestedUrl != null && websiteUrlController.text.isEmpty) { websiteUrlController.text = suggestedUrl; } diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index ae56ef7..bf05c79 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; import '../models/category_model.dart'; @@ -439,9 +440,32 @@ class DetailScreenController extends ChangeNotifier { /// 해지 페이지 열기 Future openCancellationPage() async { - if (subscription.websiteUrl != null && - subscription.websiteUrl!.isNotEmpty) { - final Uri url = Uri.parse(subscription.websiteUrl!); + try { + // 1. 현재 언어 설정 가져오기 + final locale = Localizations.localeOf(context).languageCode; + + // 2. 해지 안내 URL 찾기 + String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl( + serviceName: subscription.serviceName, + websiteUrl: subscription.websiteUrl, + locale: locale == 'ko' ? 'kr' : 'en', + ); + + // 3. 해지 안내 URL이 없으면 구글 검색 + if (cancellationUrl == null) { + final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}'; + cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}'; + + if (context.mounted) { + AppSnackBar.showInfo( + context: context, + message: '공식 해지 페이지를 찾을 수 없어 구글 검색으로 연결합니다.', + ); + } + } + + // 4. URL 열기 + final Uri url = Uri.parse(cancellationUrl); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { if (context.mounted) { AppSnackBar.showError( @@ -450,12 +474,30 @@ class DetailScreenController extends ChangeNotifier { ); } } - } else { - if (context.mounted) { - AppSnackBar.showWarning( - context: context, - message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.', - ); + } catch (e) { + if (kDebugMode) { + print('DetailScreenController: 해지 페이지 열기 실패 - $e'); + } + + // 오류 발생시 일반 웹사이트로 폴백 + if (subscription.websiteUrl != null && + subscription.websiteUrl!.isNotEmpty) { + final Uri url = Uri.parse(subscription.websiteUrl!); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + if (context.mounted) { + AppSnackBar.showError( + context: context, + message: '웹사이트를 열 수 없습니다.', + ); + } + } + } else { + if (context.mounted) { + AppSnackBar.showWarning( + context: context, + message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.', + ); + } } } } diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index 8e4007c..d9c676c 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -1,6 +1,34 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; + +/// 서비스 정보를 담는 데이터 클래스 +class ServiceInfo { + final String serviceId; + final String serviceName; + final String? serviceUrl; + final String? cancellationUrl; + final String categoryId; + final String categoryNameKr; + final String categoryNameEn; + + ServiceInfo({ + required this.serviceId, + required this.serviceName, + this.serviceUrl, + this.cancellationUrl, + required this.categoryId, + required this.categoryNameKr, + required this.categoryNameEn, + }); +} /// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 class SubscriptionUrlMatcher { + static Map? _servicesData; + static bool _isInitialized = false; + + // 레거시 데이터 (JSON 로드 실패시 폴백) // OTT 서비스 static final Map ottServices = { 'netflix': 'https://www.netflix.com', @@ -339,58 +367,127 @@ class SubscriptionUrlMatcher { static final Map allServices = { ...ottServices, ...musicServices, + ...storageServices, ...aiServices, ...programmingServices, ...officeTools, + ...lifestyleServices, + ...shoppingServices, + ...telecomServices, ...otherServices, }; - - /// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 - /// - /// [text] 검색할 텍스트 (서비스명) - /// [usePartialMatch] 부분 일치도 허용할지 여부 (기본값: true) - /// - /// 반환값: 매칭된 URL 또는 null (매칭 실패시) - static String? findMatchingUrl(String text, {bool usePartialMatch = true}) { - // 입력 텍스트가 비어있거나 null인 경우 - if (text.isEmpty) { + + /// JSON 데이터 초기화 + static Future initialize() async { + if (_isInitialized) return; + + try { + final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); + _servicesData = json.decode(jsonString); + _isInitialized = true; + print('SubscriptionUrlMatcher: JSON 데이터 로드 완료'); + } catch (e) { + print('SubscriptionUrlMatcher: JSON 로드 실패 - $e'); + // 로드 실패시 기존 하드코딩 데이터 사용 + _isInitialized = true; + } + } + + /// 도메인 추출 (www와 TLD 제외) + static String? extractDomain(String url) { + try { + final uri = Uri.parse(url); + final host = uri.host.toLowerCase(); + + // 도메인 부분 추출 + var parts = host.split('.'); + + // www 제거 + if (parts.isNotEmpty && parts[0] == 'www') { + parts = parts.sublist(1); + } + + // 서브도메인 처리 (예: music.youtube.com) + if (parts.length >= 3) { + // 서브도메인 포함 전체 도메인 반환 + return parts.sublist(0, parts.length - 1).join('.'); + } else if (parts.length >= 2) { + // 메인 도메인만 반환 + return parts[0]; + } + + return null; + } catch (e) { + print('SubscriptionUrlMatcher: 도메인 추출 실패 - $e'); return null; } - - // 소문자로 변환하여 처리 - final String lowerText = text.toLowerCase().trim(); - - // 정확히 일치하는 경우 - if (allServices.containsKey(lowerText)) { - return allServices[lowerText]; - } - - // 부분 일치 검색이 활성화된 경우 - if (usePartialMatch) { - // 가장 긴 부분 매칭 찾기 - String? bestMatch; - int maxLength = 0; - - for (var entry in allServices.entries) { - final String key = entry.key; - - // 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우 - if (lowerText.contains(key) || key.contains(lowerText)) { - // 더 긴 매칭을 우선시 - if (key.length > maxLength) { - maxLength = key.length; - bestMatch = entry.value; + } + + /// URL로 서비스 찾기 + static Future findServiceByUrl(String url) async { + await initialize(); + + final domain = extractDomain(url); + if (domain == null) return null; + + // JSON 데이터가 있으면 JSON에서 찾기 + if (_servicesData != null) { + final categories = _servicesData!['categories'] as Map; + + for (final categoryEntry in categories.entries) { + final categoryId = categoryEntry.key; + final categoryData = categoryEntry.value as Map; + final services = categoryData['services'] as Map; + + for (final serviceEntry in services.entries) { + final serviceId = serviceEntry.key; + final serviceData = serviceEntry.value as Map; + final domains = List.from(serviceData['domains'] ?? []); + + // 도메인이 일치하는지 확인 + for (final serviceDomain in domains) { + if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { + final names = List.from(serviceData['names'] ?? []); + final urls = serviceData['urls'] as Map?; + + return ServiceInfo( + serviceId: serviceId, + serviceName: names.isNotEmpty ? names[0] : serviceId, + serviceUrl: urls?['kr'] ?? urls?['en'], + cancellationUrl: null, + categoryId: _getCategoryIdByKey(categoryId), + categoryNameKr: categoryData['nameKr'] ?? '', + categoryNameEn: categoryData['nameEn'] ?? '', + ); + } } } } - - return bestMatch; } - + + // JSON에서 못 찾았으면 레거시 방식으로 찾기 + for (final entry in allServices.entries) { + final serviceUrl = entry.value; + final serviceDomain = extractDomain(serviceUrl); + + if (serviceDomain != null && + (domain.contains(serviceDomain) || serviceDomain.contains(domain))) { + return ServiceInfo( + serviceId: entry.key, + serviceName: entry.key, + serviceUrl: serviceUrl, + cancellationUrl: null, + categoryId: _getCategoryForLegacyService(entry.key), + categoryNameKr: '', + categoryNameEn: '', + ); + } + } + return null; } - - /// 서비스 이름을 기반으로 URL 제안 + + /// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지) static String? suggestUrl(String serviceName) { if (serviceName.isEmpty) { print('SubscriptionUrlMatcher: 빈 serviceName'); @@ -498,13 +595,74 @@ class SubscriptionUrlMatcher { return null; } } + + /// 해지 안내 URL 찾기 (개선된 버전) + static Future findCancellationUrl({ + String? serviceName, + String? websiteUrl, + String locale = 'kr', + }) async { + await initialize(); + + // JSON 데이터가 있으면 JSON에서 찾기 + if (_servicesData != null) { + final categories = _servicesData!['categories'] as Map; + + // 1. 서비스명으로 찾기 + if (serviceName != null && serviceName.isNotEmpty) { + final lowerName = serviceName.toLowerCase().trim(); + + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final names = List.from((serviceData as Map)['names'] ?? []); + + for (final name in names) { + if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (cancellationUrls != null) { + // 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환 + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + } + } + } + } + } + } + + // 2. URL로 찾기 + if (websiteUrl != null && websiteUrl.isNotEmpty) { + final domain = extractDomain(websiteUrl); + if (domain != null) { + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final domains = List.from((serviceData as Map)['domains'] ?? []); + + for (final serviceDomain in domains) { + if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { + final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (cancellationUrls != null) { + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + } + } + } + } + } + } + } + } + + // JSON에서 못 찾았으면 레거시 방식으로 찾기 + return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); + } - /// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 - /// - /// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL - /// - /// 반환값: 해지 안내 페이지 URL 또는 null (해지 안내 페이지가 없는 경우) - static String? findCancellationUrl(String serviceNameOrUrl) { + /// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시) + static String? _findCancellationUrlLegacy(String serviceNameOrUrl) { if (serviceNameOrUrl.isEmpty) { return null; } @@ -548,11 +706,182 @@ class SubscriptionUrlMatcher { } /// 서비스에 공식 해지 안내 페이지가 있는지 확인 - /// - /// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL - /// - /// 반환값: 해지 안내 페이지 제공 여부 - static bool hasCancellationPage(String serviceNameOrUrl) { - return findCancellationUrl(serviceNameOrUrl) != null; + static Future hasCancellationPage(String serviceNameOrUrl) async { + // 새로운 JSON 기반 방식으로 확인 + final cancellationUrl = await findCancellationUrl( + serviceName: serviceNameOrUrl, + websiteUrl: serviceNameOrUrl, + ); + return cancellationUrl != null; } -} + + /// 서비스명으로 카테고리 찾기 + static Future findCategoryByServiceName(String serviceName) async { + await initialize(); + if (serviceName.isEmpty) return null; + + final lowerName = serviceName.toLowerCase().trim(); + + // JSON 데이터가 있으면 JSON에서 찾기 + if (_servicesData != null) { + final categories = _servicesData!['categories'] as Map; + + for (final categoryEntry in categories.entries) { + final categoryId = categoryEntry.key; + final categoryData = categoryEntry.value as Map; + final services = categoryData['services'] as Map; + + for (final serviceData in services.values) { + final names = List.from((serviceData as Map)['names'] ?? []); + + for (final name in names) { + if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + return _getCategoryIdByKey(categoryId); + } + } + } + } + } + + // JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측 + return _getCategoryForLegacyService(serviceName); + } + + /// 카테고리 키를 실제 카테고리 ID로 매핑 + static String _getCategoryIdByKey(String key) { + // 여기에 실제 앱의 카테고리 ID 매핑을 추가 + // 임시로 카테고리명 기반 매핑 + switch (key) { + case 'music': + return 'music_streaming'; + case 'ott': + return 'ott_services'; + case 'storage': + return 'cloud_storage'; + case 'ai': + return 'ai_services'; + case 'programming': + return 'dev_tools'; + case 'office': + return 'office_tools'; + case 'lifestyle': + return 'lifestyle'; + case 'shopping': + return 'shopping'; + case 'gaming': + return 'gaming'; + case 'telecom': + return 'telecom'; + default: + return 'other'; + } + } + + /// 레거시 서비스명으로 카테고리 추측 + static String _getCategoryForLegacyService(String serviceName) { + final lowerName = serviceName.toLowerCase(); + + if (ottServices.containsKey(lowerName)) return 'ott_services'; + if (musicServices.containsKey(lowerName)) return 'music_streaming'; + if (storageServices.containsKey(lowerName)) return 'cloud_storage'; + if (aiServices.containsKey(lowerName)) return 'ai_services'; + if (programmingServices.containsKey(lowerName)) return 'dev_tools'; + if (officeTools.containsKey(lowerName)) return 'office_tools'; + if (lifestyleServices.containsKey(lowerName)) return 'lifestyle'; + if (shoppingServices.containsKey(lowerName)) return 'shopping'; + if (telecomServices.containsKey(lowerName)) return 'telecom'; + + return 'other'; + } + + /// SMS에서 URL과 서비스 정보 추출 + static Future extractServiceFromSms(String smsText) async { + await initialize(); + + // URL 패턴 찾기 + final urlPattern = RegExp( + r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)', + caseSensitive: false, + ); + + final matches = urlPattern.allMatches(smsText); + + for (final match in matches) { + final url = match.group(0); + if (url != null) { + final serviceInfo = await findServiceByUrl(url); + if (serviceInfo != null) { + return serviceInfo; + } + } + } + + // URL로 못 찾았으면 서비스명으로 시도 + final lowerSms = smsText.toLowerCase(); + + // 모든 서비스명 검사 + for (final entry in allServices.entries) { + if (lowerSms.contains(entry.key.toLowerCase())) { + final categoryId = await findCategoryByServiceName(entry.key) ?? 'other'; + + return ServiceInfo( + serviceId: entry.key, + serviceName: entry.key, + serviceUrl: entry.value, + cancellationUrl: null, + categoryId: categoryId, + categoryNameKr: '', + categoryNameEn: '', + ); + } + } + + return null; + } + + /// URL이 알려진 서비스 URL인지 확인 + static Future isKnownServiceUrl(String url) async { + final serviceInfo = await findServiceByUrl(url); + return serviceInfo != null; + } + + /// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성) + static String? findMatchingUrl(String text, {bool usePartialMatch = true}) { + // 입력 텍스트가 비어있거나 null인 경우 + if (text.isEmpty) { + return null; + } + + // 소문자로 변환하여 처리 + final String lowerText = text.toLowerCase().trim(); + + // 정확히 일치하는 경우 + if (allServices.containsKey(lowerText)) { + return allServices[lowerText]; + } + + // 부분 일치 검색이 활성화된 경우 + if (usePartialMatch) { + // 가장 긴 부분 매칭 찾기 + String? bestMatch; + int maxLength = 0; + + for (var entry in allServices.entries) { + final String key = entry.key; + + // 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우 + if (lowerText.contains(key) || key.contains(lowerText)) { + // 더 긴 매칭을 우선시 + if (key.length > maxLength) { + maxLength = key.length; + bestMatch = entry.value; + } + } + } + + return bestMatch; + } + + return null; + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 8d1d815..67ce57f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,3 +53,6 @@ dev_dependencies: flutter: uses-material-design: true + + assets: + - assets/data/