From 5a7ef8039e71adabe3e6d523e01aed18ee630424 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 8 Sep 2025 14:33:55 +0900 Subject: [PATCH] refactor: remove unreferenced widgets/utilities and backup file in lib --- .../subscription_url_matcher.dart.backup | 941 ------------------ lib/utils/format_helper.dart | 37 - lib/widgets/common/buttons/danger_button.dart | 173 ---- lib/widgets/common/cards/section_card.dart | 230 ----- .../common/dialogs/loading_overlay.dart | 240 ----- lib/widgets/exchange_rate_widget.dart | 153 --- lib/widgets/expandable_fab.dart | 269 ----- lib/widgets/glassmorphic_app_bar.dart | 320 ------ lib/widgets/main_summary_card.dart | 592 +++++------ lib/widgets/skeleton_loading.dart | 159 --- lib/widgets/spring_animation_widget.dart | 340 ------- 11 files changed, 299 insertions(+), 3155 deletions(-) delete mode 100644 lib/services/subscription_url_matcher.dart.backup delete mode 100644 lib/utils/format_helper.dart delete mode 100644 lib/widgets/common/buttons/danger_button.dart delete mode 100644 lib/widgets/common/cards/section_card.dart delete mode 100644 lib/widgets/common/dialogs/loading_overlay.dart delete mode 100644 lib/widgets/exchange_rate_widget.dart delete mode 100644 lib/widgets/expandable_fab.dart delete mode 100644 lib/widgets/glassmorphic_app_bar.dart delete mode 100644 lib/widgets/skeleton_loading.dart delete mode 100644 lib/widgets/spring_animation_widget.dart diff --git a/lib/services/subscription_url_matcher.dart.backup b/lib/services/subscription_url_matcher.dart.backup deleted file mode 100644 index a6c46eb..0000000 --- a/lib/services/subscription_url_matcher.dart.backup +++ /dev/null @@ -1,941 +0,0 @@ -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', - '넷플릭스': 'https://www.netflix.com', - 'disney+': 'https://www.disneyplus.com', - '디즈니플러스': 'https://www.disneyplus.com', - 'youtube premium': 'https://www.youtube.com/premium', - '유튜브 프리미엄': 'https://www.youtube.com/premium', - 'watcha': 'https://watcha.com', - '왓챠': 'https://watcha.com', - 'wavve': 'https://www.wavve.com', - '웨이브': 'https://www.wavve.com', - 'apple tv+': 'https://tv.apple.com', - '애플 티비플러스': 'https://tv.apple.com', - 'tving': 'https://www.tving.com', - '티빙': 'https://www.tving.com', - 'prime video': 'https://www.primevideo.com', - '프라임 비디오': 'https://www.primevideo.com', - 'amazon prime': 'https://www.amazon.com/prime', - '아마존 프라임': 'https://www.amazon.com/prime', - 'coupang play': 'https://play.coupangplay.com', - '쿠팡 플레이': 'https://play.coupangplay.com', - 'hulu': 'https://www.hulu.com', - '훌루': 'https://www.hulu.com', - }; - - // 음악 서비스 - static final Map musicServices = { - 'spotify': 'https://www.spotify.com', - '스포티파이': 'https://www.spotify.com', - 'apple music': 'https://music.apple.com', - '애플 뮤직': 'https://music.apple.com', - 'melon': 'https://www.melon.com', - '멜론': 'https://www.melon.com', - 'genie': 'https://www.genie.co.kr', - '지니': 'https://www.genie.co.kr', - 'youtube music': 'https://music.youtube.com', - '유튜브 뮤직': 'https://music.youtube.com', - 'bugs': 'https://music.bugs.co.kr', - '벅스': 'https://music.bugs.co.kr', - 'flo': 'https://www.music-flo.com', - '플로': 'https://www.music-flo.com', - 'vibe': 'https://vibe.naver.com', - '바이브': 'https://vibe.naver.com', - 'tidal': 'https://www.tidal.com', - '타이달': 'https://www.tidal.com', - }; - - // 저장 (클라우드/파일) 서비스 - static final Map storageServices = { - 'google drive': 'https://www.google.com/drive/', - '구글 드라이브': 'https://www.google.com/drive/', - 'dropbox': 'https://www.dropbox.com', - '드롭박스': 'https://www.dropbox.com', - 'onedrive': 'https://www.onedrive.com', - '원드라이브': 'https://www.onedrive.com', - 'icloud': 'https://www.icloud.com', - '아이클라우드': 'https://www.icloud.com', - 'box': 'https://www.box.com', - '박스': 'https://www.box.com', - 'pcloud': 'https://www.pcloud.com', - 'mega': 'https://mega.nz', - '메가': 'https://mega.nz', - 'naver mybox': 'https://mybox.naver.com', - '네이버 마이박스': 'https://mybox.naver.com', - }; - - // 통신 · 인터넷 · TV 서비스 - static final Map telecomServices = { - 'skt': 'https://www.sktelecom.com', - 'sk텔레콤': 'https://www.sktelecom.com', - 'kt': 'https://www.kt.com', - 'lgu+': 'https://www.lguplus.com', - 'lg유플러스': 'https://www.lguplus.com', - 'olleh tv': 'https://www.kt.com/olleh_tv', - '올레 tv': 'https://www.kt.com/olleh_tv', - 'b tv': 'https://www.skbroadband.com', - '비티비': 'https://www.skbroadband.com', - 'u+모바일tv': 'https://www.lguplus.com', - '유플러스모바일tv': 'https://www.lguplus.com', - }; - - // 생활/라이프스타일 서비스 - static final Map lifestyleServices = { - '네이버 플러스': 'https://plus.naver.com', - 'naver plus': 'https://plus.naver.com', - '카카오 구독': 'https://subscribe.kakao.com', - 'kakao subscribe': 'https://subscribe.kakao.com', - '쿠팡 와우': 'https://www.coupang.com/np/coupangplus', - 'coupang wow': 'https://www.coupang.com/np/coupangplus', - '스타벅스 버디': 'https://www.starbucks.co.kr', - 'starbucks buddy': 'https://www.starbucks.co.kr', - 'cu 구독': 'https://cu.bgfretail.com', - 'gs25 구독': 'https://gs25.gsretail.com', - '현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription', - 'lg전자 구독': 'https://www.lge.co.kr', - '삼성전자 구독': 'https://www.samsung.com/sec', - '다이슨 케어': 'https://www.dyson.co.kr', - 'dyson care': 'https://www.dyson.co.kr', - '마켓컬리': 'https://www.kurly.com', - 'kurly': 'https://www.kurly.com', - '헬로네이처': 'https://www.hellonature.com', - 'hello nature': 'https://www.hellonature.com', - '이마트 트레이더스': 'https://www.emarttraders.co.kr', - '홈플러스': 'https://www.homeplus.co.kr', - 'hellofresh': 'https://www.hellofresh.com', - '헬로프레시': 'https://www.hellofresh.com', - 'bespoke post': 'https://www.bespokepost.com', - }; - - // 쇼핑/이커머스 서비스 - static final Map shoppingServices = { - 'amazon prime': 'https://www.amazon.com/prime', - '아마존 프라임': 'https://www.amazon.com/prime', - 'walmart+': 'https://www.walmart.com/plus', - '월마트플러스': 'https://www.walmart.com/plus', - 'chewy': 'https://www.chewy.com', - '츄이': 'https://www.chewy.com', - 'dollar shave club': 'https://www.dollarshaveclub.com', - '달러셰이브클럽': 'https://www.dollarshaveclub.com', - 'instacart': 'https://www.instacart.com', - '인스타카트': 'https://www.instacart.com', - 'shipt': 'https://www.shipt.com', - '십트': 'https://www.shipt.com', - 'grove': 'https://grove.co', - '그로브': 'https://grove.co', - 'cratejoy': 'https://www.cratejoy.com', - 'shopify': 'https://www.shopify.com', - '쇼피파이': 'https://www.shopify.com', - }; - - // AI 서비스 - static final Map aiServices = { - 'chatgpt': 'https://chat.openai.com', - '챗GPT': 'https://chat.openai.com', - 'openai': 'https://openai.com', - '오픈AI': 'https://openai.com', - 'claude': 'https://claude.ai', - '클로드': 'https://claude.ai', - 'anthropic': 'https://www.anthropic.com', - '앤트로픽': 'https://www.anthropic.com', - 'midjourney': 'https://www.midjourney.com', - '미드저니': 'https://www.midjourney.com', - 'perplexity': 'https://www.perplexity.ai', - '퍼플렉시티': 'https://www.perplexity.ai', - 'copilot': 'https://copilot.microsoft.com', - '코파일럿': 'https://copilot.microsoft.com', - 'gemini': 'https://gemini.google.com', - '제미니': 'https://gemini.google.com', - 'google ai': 'https://ai.google', - '구글 AI': 'https://ai.google', - 'bard': 'https://bard.google.com', - '바드': 'https://bard.google.com', - 'dall-e': 'https://openai.com/dall-e', - '달리': 'https://openai.com/dall-e', - 'stable diffusion': 'https://stability.ai', - '스테이블 디퓨전': 'https://stability.ai', - }; - - // 프로그래밍 / 개발 서비스 - static final Map programmingServices = { - 'github': 'https://github.com', - '깃허브': 'https://github.com', - 'cursor': 'https://cursor.com', - '커서': 'https://cursor.com', - 'jetbrains': 'https://www.jetbrains.com', - '제트브레인스': 'https://www.jetbrains.com', - 'intellij': 'https://www.jetbrains.com/idea', - '인텔리제이': 'https://www.jetbrains.com/idea', - 'visual studio': 'https://visualstudio.microsoft.com', - '비주얼 스튜디오': 'https://visualstudio.microsoft.com', - 'aws': 'https://aws.amazon.com', - '아마존 웹서비스': 'https://aws.amazon.com', - 'azure': 'https://azure.microsoft.com', - '애저': 'https://azure.microsoft.com', - 'google cloud': 'https://cloud.google.com', - '구글 클라우드': 'https://cloud.google.com', - 'digitalocean': 'https://www.digitalocean.com', - '디지털오션': 'https://www.digitalocean.com', - 'heroku': 'https://www.heroku.com', - '헤로쿠': 'https://www.heroku.com', - 'codecademy': 'https://www.codecademy.com', - '코드아카데미': 'https://www.codecademy.com', - 'udemy': 'https://www.udemy.com', - '유데미': 'https://www.udemy.com', - 'coursera': 'https://www.coursera.org', - '코세라': 'https://www.coursera.org', - }; - - // 오피스 및 협업 툴 - static final Map officeTools = { - 'microsoft 365': 'https://www.microsoft.com/microsoft-365', - '마이크로소프트 365': 'https://www.microsoft.com/microsoft-365', - 'office 365': 'https://www.microsoft.com/microsoft-365', - '오피스 365': 'https://www.microsoft.com/microsoft-365', - 'google workspace': 'https://workspace.google.com', - '구글 워크스페이스': 'https://workspace.google.com', - 'slack': 'https://slack.com', - '슬랙': 'https://slack.com', - 'notion': 'https://www.notion.so', - '노션': 'https://www.notion.so', - 'trello': 'https://trello.com', - '트렐로': 'https://trello.com', - 'asana': 'https://asana.com', - '아사나': 'https://asana.com', - 'dropbox': 'https://www.dropbox.com', - '드롭박스': 'https://www.dropbox.com', - 'figma': 'https://www.figma.com', - '피그마': 'https://www.figma.com', - 'adobe creative cloud': 'https://www.adobe.com/creativecloud.html', - '어도비 크리에이티브 클라우드': 'https://www.adobe.com/creativecloud.html', - }; - - // 기타 유명 서비스 - static final Map otherServices = { - 'google one': 'https://one.google.com', - '구글 원': 'https://one.google.com', - 'icloud': 'https://www.icloud.com', - '아이클라우드': 'https://www.icloud.com', - 'nintendo switch online': 'https://www.nintendo.com/switch/online-service', - '닌텐도 스위치 온라인': 'https://www.nintendo.com/switch/online-service', - 'playstation plus': 'https://www.playstation.com/ps-plus', - '플레이스테이션 플러스': 'https://www.playstation.com/ps-plus', - 'xbox game pass': 'https://www.xbox.com/xbox-game-pass', - '엑스박스 게임 패스': 'https://www.xbox.com/xbox-game-pass', - 'ea play': 'https://www.ea.com/ea-play', - 'EA 플레이': 'https://www.ea.com/ea-play', - 'ubisoft+': 'https://ubisoft.com/plus', - '유비소프트+': 'https://ubisoft.com/plus', - 'epic games': 'https://www.epicgames.com', - '에픽 게임즈': 'https://www.epicgames.com', - 'steam': 'https://store.steampowered.com', - '스팀': 'https://store.steampowered.com', - }; - - // 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들) - static final Map cancellationUrls = { - // OTT 서비스 해지 안내 페이지 - 'netflix': 'https://help.netflix.com/ko/node/407', - '넷플릭스': 'https://help.netflix.com/ko/node/407', - 'disney+': - 'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979', - '디즈니플러스': - 'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979', - 'youtube premium': 'https://support.google.com/youtube/answer/6308278', - '유튜브 프리미엄': 'https://support.google.com/youtube/answer/6308278', - 'watcha': 'https://watcha.com/settings/payment', - '왓챠': 'https://watcha.com/settings/payment', - 'wavve': 'https://www.wavve.com/my', - '웨이브': 'https://www.wavve.com/my', - 'apple tv+': 'https://support.apple.com/ko-kr/HT202039', - '애플 티비플러스': 'https://support.apple.com/ko-kr/HT202039', - 'tving': 'https://www.tving.com/my/cancelMembership', - '티빙': 'https://www.tving.com/my/cancelMembership', - 'amazon prime': 'https://www.amazon.com/gp/primecentral/managemembership', - '아마존 프라임': 'https://www.amazon.com/gp/primecentral/managemembership', - - // 음악 서비스 해지 안내 페이지 - 'spotify': 'https://support.spotify.com/us/article/cancel-premium/', - '스포티파이': 'https://support.spotify.com/us/article/cancel-premium/', - 'apple music': 'https://support.apple.com/ko-kr/HT202039', - '애플 뮤직': 'https://support.apple.com/ko-kr/HT202039', - 'melon': - 'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021', - '멜론': - 'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021', - 'youtube music': 'https://support.google.com/youtubemusic/answer/6308278', - '유튜브 뮤직': 'https://support.google.com/youtubemusic/answer/6308278', - - // AI 서비스 해지 안내 페이지 - 'chatgpt': - 'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription', - '챗GPT': - 'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription', - 'claude': - 'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription', - '클로드': - 'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription', - 'midjourney': 'https://docs.midjourney.com/docs/manage-subscription', - '미드저니': 'https://docs.midjourney.com/docs/manage-subscription', - - // 프로그래밍 / 개발 서비스 해지 안내 페이지 - 'github': - 'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription', - '깃허브': - 'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription', - 'jetbrains': - 'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-', - '제트브레인스': - 'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-', - - // 오피스 및 협업 툴 해지 안내 페이지 - 'microsoft 365': - 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', - '마이크로소프트 365': - 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', - 'office 365': - 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', - '오피스 365': - 'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b', - 'slack': - 'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription', - '슬랙': - 'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription', - 'notion': - 'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription', - '노션': - 'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription', - 'dropbox': 'https://help.dropbox.com/accounts-billing/cancellation', - '드롭박스': 'https://help.dropbox.com/accounts-billing/cancellation', - 'adobe creative cloud': - 'https://helpx.adobe.com/manage-account/using/cancel-subscription.html', - '어도비 크리에이티브 클라우드': - 'https://helpx.adobe.com/manage-account/using/cancel-subscription.html', - - // 기타 유명 서비스 해지 안내 페이지 - 'google one': 'https://support.google.com/googleone/answer/9140429', - '구글 원': 'https://support.google.com/googleone/answer/9140429', - 'icloud': 'https://support.apple.com/ko-kr/HT207594', - '아이클라우드': 'https://support.apple.com/ko-kr/HT207594', - 'nintendo switch online': - 'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership', - '닌텐도 스위치 온라인': - 'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership', - 'playstation plus': - 'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/', - '플레이스테이션 플러스': - 'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/', - 'xbox game pass': - 'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel', - '엑스박스 게임 패스': - 'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel', - }; - - // 모든 서비스 매핑을 합친 맵 - static final Map allServices = { - ...ottServices, - ...musicServices, - ...storageServices, - ...aiServices, - ...programmingServices, - ...officeTools, - ...lifestyleServices, - ...shoppingServices, - ...telecomServices, - ...otherServices, - }; - - /// 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; - } - } - - /// 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'] ?? '', - ); - } - } - } - } - } - - // 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 찾기 (기존 suggestUrl 메서드 유지) - static String? suggestUrl(String serviceName) { - if (serviceName.isEmpty) { - print('SubscriptionUrlMatcher: 빈 serviceName'); - return null; - } - - // 소문자로 변환하여 비교 - final lowerName = serviceName.toLowerCase().trim(); - - try { - // 정확한 매칭을 먼저 시도 - for (final entry in allServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // OTT 서비스 검사 - for (final entry in ottServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 음악 서비스 검사 - for (final entry in musicServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // AI 서비스 검사 - for (final entry in aiServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 개발 서비스 검사 - for (final entry in programmingServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 오피스 툴 검사 - for (final entry in officeTools.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 기타 서비스 검사 - for (final entry in otherServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색 - for (final entry in allServices.entries) { - final serviceWords = lowerName.split(' '); - final keyWords = entry.key.toLowerCase().split(' '); - - // 단어 단위로 일치하는지 확인 - for (final word in serviceWords) { - if (word.length > 2 && - keyWords.any((keyWord) => keyWord.contains(word))) { - print( - 'SubscriptionUrlMatcher: 단어 기반 매칭 - $word (in $lowerName) -> ${entry.key}'); - return entry.value; - } - } - } - - // 추출 가능한 도메인이 있는지 확인 - final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName); - if (domainMatch != null && domainMatch.group(1)!.length > 2) { - final domain = domainMatch.group(1)!.trim(); - if (domain.length > 2 && - !['the', 'and', 'for', 'www'].contains(domain)) { - final url = 'https://www.$domain.com'; - print('SubscriptionUrlMatcher: 도메인 추출 - $lowerName -> $url'); - return url; - } - } - - print('SubscriptionUrlMatcher: 매칭 실패 - $lowerName'); - return null; - } catch (e) { - print('SubscriptionUrlMatcher: URL 매칭 중 오류 발생: $e'); - 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 찾기 (레거시) - static String? _findCancellationUrlLegacy(String serviceNameOrUrl) { - if (serviceNameOrUrl.isEmpty) { - return null; - } - - // 소문자로 변환하여 처리 - final String lowerText = serviceNameOrUrl.toLowerCase().trim(); - - // 직접 서비스명으로 찾기 - if (cancellationUrls.containsKey(lowerText)) { - return cancellationUrls[lowerText]; - } - - // 서비스명에 부분 포함으로 찾기 - for (var entry in cancellationUrls.entries) { - final String key = entry.key.toLowerCase(); - if (lowerText.contains(key) || key.contains(lowerText)) { - return entry.value; - } - } - - // URL을 통해 서비스명 추출 후 찾기 - if (lowerText.startsWith('http')) { - // URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출) - final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)'); - final match = domainRegex.firstMatch(lowerText); - - if (match != null && match.groupCount >= 1) { - final domain = match.group(1)?.toLowerCase() ?? ''; - - // 도메인으로 서비스명 찾기 - for (var entry in cancellationUrls.entries) { - if (entry.key.toLowerCase().contains(domain)) { - return entry.value; - } - } - } - } - - // 해지 안내 페이지를 찾지 못함 - return 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); - } - - /// 현재 로케일에 따라 서비스 표시명 가져오기 - 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 매핑을 추가 - // 임시로 카테고리명 기반 매핑 - 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/lib/utils/format_helper.dart b/lib/utils/format_helper.dart deleted file mode 100644 index 3f174ac..0000000 --- a/lib/utils/format_helper.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:intl/intl.dart'; - -/// 숫자와 날짜를 포맷팅하는 유틸리티 클래스 -class FormatHelper { - /// 통화 형식으로 숫자 포맷팅 - static String formatCurrency(double value) { - return NumberFormat.currency( - locale: 'ko_KR', - symbol: '', - decimalDigits: 0, - ).format(value); - } - - /// 날짜를 yyyy년 MM월 dd일 형식으로 포맷팅 - static String formatDate(DateTime date) { - return '${date.year}년 ${date.month}월 ${date.day}일'; - } - - /// 날짜를 MM.dd 형식으로 포맷팅 (짧은 형식) - static String formatShortDate(DateTime date) { - return '${date.month}.${date.day}'; - } - - /// 현재 날짜로부터 남은 일 수 계산 - static String getRemainingDays(DateTime date) { - final now = DateTime.now(); - final difference = date.difference(now).inDays; - - if (difference < 0) { - return '${-difference}일 지남'; - } else if (difference == 0) { - return '오늘'; - } else { - return '$difference일 후'; - } - } -} diff --git a/lib/widgets/common/buttons/danger_button.dart b/lib/widgets/common/buttons/danger_button.dart deleted file mode 100644 index 5b9a2a9..0000000 --- a/lib/widgets/common/buttons/danger_button.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../../theme/app_colors.dart'; - -/// 위험한 액션에 사용되는 Danger 버튼 -/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다. -class DangerButton extends StatefulWidget { - final String text; - final VoidCallback? onPressed; - final bool requireConfirmation; - final String? confirmationTitle; - final String? confirmationMessage; - final IconData? icon; - final double? width; - final double height; - final double fontSize; - final EdgeInsetsGeometry? padding; - final double borderRadius; - final bool enableHoverEffect; - - const DangerButton({ - super.key, - required this.text, - this.onPressed, - this.requireConfirmation = false, - this.confirmationTitle, - this.confirmationMessage, - this.icon, - this.width, - this.height = 60, - this.fontSize = 18, - this.padding, - this.borderRadius = 16, - this.enableHoverEffect = true, - }); - - @override - State createState() => _DangerButtonState(); -} - -class _DangerButtonState extends State { - bool _isHovered = false; - - static const Color _dangerColor = AppColors.dangerColor; - - Future _handlePress() async { - if (widget.requireConfirmation) { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - title: Text( - widget.confirmationTitle ?? widget.text, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: _dangerColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - widget.icon ?? Icons.warning_amber_rounded, - color: _dangerColor, - size: 48, - ), - ), - const SizedBox(height: 16), - Text( - widget.confirmationMessage ?? '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - height: 1.5, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('취소'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: _dangerColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - widget.text, - style: const TextStyle(color: AppColors.pureWhite), - ), - ), - ], - ), - ); - - if (confirmed == true) { - widget.onPressed?.call(); - } - } else { - widget.onPressed?.call(); - } - } - - @override - Widget build(BuildContext context) { - Widget button = AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: widget.width ?? double.infinity, - height: widget.height, - transform: widget.enableHoverEffect && _isHovered - ? (Matrix4.identity()..scale(1.02)) - : Matrix4.identity(), - child: ElevatedButton( - onPressed: widget.onPressed != null ? _handlePress : null, - style: ElevatedButton.styleFrom( - backgroundColor: _dangerColor, - foregroundColor: AppColors.pureWhite, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(widget.borderRadius), - ), - padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), - elevation: widget.enableHoverEffect && _isHovered ? 2 : 0, - shadowColor: Colors.black.withValues(alpha: 0.08), - disabledBackgroundColor: _dangerColor.withValues(alpha: 0.6), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.icon != null) ...[ - Icon( - widget.icon, - color: AppColors.pureWhite, - size: _isHovered ? 24 : 20, - ), - const SizedBox(width: 8), - ], - Text( - widget.text, - style: TextStyle( - fontSize: widget.fontSize, - fontWeight: FontWeight.w600, - color: AppColors.pureWhite, - ), - ), - ], - ), - ), - ); - - if (widget.enableHoverEffect) { - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: button, - ); - } - - return button; - } -} diff --git a/lib/widgets/common/cards/section_card.dart b/lib/widgets/common/cards/section_card.dart deleted file mode 100644 index 22e8275..0000000 --- a/lib/widgets/common/cards/section_card.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 섹션별 컨텐츠를 감싸는 기본 카드 위젯 -/// 폼 섹션, 정보 표시 섹션 등에 사용됩니다. -class SectionCard extends StatelessWidget { - final String? title; - final Widget child; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final Color? backgroundColor; - final double borderRadius; - final List? boxShadow; - final Border? border; - final double? height; - final double? width; - final VoidCallback? onTap; - - const SectionCard({ - super.key, - this.title, - required this.child, - this.padding, - this.margin, - this.backgroundColor, - this.borderRadius = 20, - this.boxShadow, - this.border, - this.height, - this.width, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final effectiveBackgroundColor = backgroundColor ?? Colors.white; - final effectiveShadow = boxShadow ?? - [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ]; - - Widget card = Container( - height: height, - width: width, - margin: margin, - decoration: BoxDecoration( - color: effectiveBackgroundColor, - borderRadius: BorderRadius.circular(borderRadius), - boxShadow: effectiveShadow, - border: border, - ), - child: Padding( - padding: padding ?? const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (title != null) ...[ - Text( - title!, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: theme.colorScheme.onSurface, - ), - ), - const SizedBox(height: 16), - ], - child, - ], - ), - ), - ); - - if (onTap != null) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(borderRadius), - child: card, - ); - } - - return card; - } -} - -/// 투명한 배경의 섹션 카드 -/// 어두운 배경 위에서 사용하기 적합합니다. -class TransparentSectionCard extends StatelessWidget { - final String? title; - final Widget child; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final double opacity; - final double borderRadius; - final Color? borderColor; - final VoidCallback? onTap; - - const TransparentSectionCard({ - super.key, - this.title, - required this.child, - this.padding, - this.margin, - this.opacity = 0.15, - this.borderRadius = 16, - this.borderColor, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - Widget card = Container( - margin: margin, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: opacity), - borderRadius: BorderRadius.circular(borderRadius), - border: borderColor != null - ? Border.all(color: borderColor!, width: 1) - : null, - ), - child: Padding( - padding: padding ?? const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (title != null) ...[ - Text( - title!, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - const SizedBox(height: 12), - ], - child, - ], - ), - ), - ); - - if (onTap != null) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(borderRadius), - child: card, - ); - } - - return card; - } -} - -/// 정보 표시용 카드 -/// 읽기 전용 정보를 표시할 때 사용합니다. -class InfoCard extends StatelessWidget { - final String label; - final String value; - final IconData? icon; - final Color? iconColor; - final Color? backgroundColor; - final EdgeInsetsGeometry? padding; - final double borderRadius; - - const InfoCard({ - super.key, - required this.label, - required this.value, - this.icon, - this.iconColor, - this.backgroundColor, - this.padding, - this.borderRadius = 12, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - padding: padding ?? const EdgeInsets.all(16), - decoration: BoxDecoration( - color: backgroundColor ?? theme.colorScheme.surface, - borderRadius: BorderRadius.circular(borderRadius), - ), - child: Row( - children: [ - if (icon != null) ...[ - Icon( - icon, - size: 24, - color: iconColor ?? theme.colorScheme.primary, - ), - const SizedBox(width: 12), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 14, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 4), - Text( - value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/common/dialogs/loading_overlay.dart b/lib/widgets/common/dialogs/loading_overlay.dart deleted file mode 100644 index 033cd95..0000000 --- a/lib/widgets/common/dialogs/loading_overlay.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 로딩 오버레이 위젯 -/// 비동기 작업 중 화면을 덮는 로딩 인디케이터를 표시합니다. -class LoadingOverlay extends StatelessWidget { - final bool isLoading; - final Widget child; - final String? message; - final Color? backgroundColor; - final Color? indicatorColor; - final double opacity; - - const LoadingOverlay({ - super.key, - required this.isLoading, - required this.child, - this.message, - this.backgroundColor, - this.indicatorColor, - this.opacity = 0.7, - }); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - child, - if (isLoading) - Container( - color: (backgroundColor ?? Colors.black).withValues(alpha: opacity), - child: Center( - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator( - color: indicatorColor ?? Theme.of(context).primaryColor, - ), - if (message != null) ...[ - const SizedBox(height: 16), - Text( - message!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ], - ), - ), - ), - ), - ], - ); - } -} - -/// 로딩 다이얼로그 -/// 모달 형태의 로딩 인디케이터를 표시합니다. -class LoadingDialog { - static Future show({ - required BuildContext context, - String? message, - Color? barrierColor, - bool barrierDismissible = false, - }) { - return showDialog( - context: context, - barrierDismissible: barrierDismissible, - barrierColor: barrierColor ?? Colors.black54, - builder: (context) => PopScope( - canPop: barrierDismissible, - child: Center( - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator( - color: Theme.of(context).primaryColor, - ), - if (message != null) ...[ - const SizedBox(height: 16), - Text( - message, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ], - ), - ), - ), - ), - ); - } - - static void hide(BuildContext context) { - Navigator.of(context).pop(); - } -} - -/// 커스텀 로딩 인디케이터 -/// 다양한 스타일의 로딩 애니메이션을 제공합니다. -class CustomLoadingIndicator extends StatefulWidget { - final double size; - final Color? color; - final LoadingStyle style; - - const CustomLoadingIndicator({ - super.key, - this.size = 50, - this.color, - this.style = LoadingStyle.circular, - }); - - @override - State createState() => _CustomLoadingIndicatorState(); -} - -class _CustomLoadingIndicatorState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(seconds: 1), - vsync: this, - )..repeat(); - - _animation = CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final effectiveColor = widget.color ?? Theme.of(context).primaryColor; - - switch (widget.style) { - case LoadingStyle.circular: - return SizedBox( - width: widget.size, - height: widget.size, - child: CircularProgressIndicator( - color: effectiveColor, - strokeWidth: 3, - ), - ); - - case LoadingStyle.dots: - return SizedBox( - width: widget.size, - height: widget.size / 3, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate(3, (index) { - return AnimatedBuilder( - animation: _animation, - builder: (context, child) { - final delay = index * 0.2; - final value = (_animation.value - delay).clamp(0.0, 1.0); - return Container( - width: widget.size / 5, - height: widget.size / 5, - decoration: BoxDecoration( - color: - effectiveColor.withValues(alpha: 0.3 + value * 0.7), - shape: BoxShape.circle, - ), - ); - }, - ); - }), - ), - ); - - case LoadingStyle.pulse: - return AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: effectiveColor.withValues(alpha: 0.3), - ), - child: Center( - child: Container( - width: widget.size * (0.3 + _animation.value * 0.5), - height: widget.size * (0.3 + _animation.value * 0.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - effectiveColor.withValues(alpha: 1 - _animation.value), - ), - ), - ), - ); - }, - ); - } - } -} - -enum LoadingStyle { - circular, - dots, - pulse, -} diff --git a/lib/widgets/exchange_rate_widget.dart b/lib/widgets/exchange_rate_widget.dart deleted file mode 100644 index 9448a34..0000000 --- a/lib/widgets/exchange_rate_widget.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/material.dart'; -import '../services/exchange_rate_service.dart'; - -/// 환율 정보를 표시하는 위젯 -/// 달러 금액을 입력받아 원화 금액으로 변환하여 표시합니다. -class ExchangeRateWidget extends StatefulWidget { - /// 달러 금액 변화 감지용 TextEditingController - final TextEditingController costController; - - /// 환율 정보를 보여줄지 여부 (통화가 달러일 때만 true) - final bool showExchangeRate; - - const ExchangeRateWidget({ - Key? key, - required this.costController, - required this.showExchangeRate, - }) : super(key: key); - - @override - State createState() => _ExchangeRateWidgetState(); -} - -class _ExchangeRateWidgetState extends State { - final ExchangeRateService _exchangeRateService = ExchangeRateService(); - String _exchangeRateInfo = ''; - String _convertedAmount = ''; - - @override - void initState() { - super.initState(); - _loadExchangeRate(); - widget.costController.addListener(_updateConvertedAmount); - } - - @override - void dispose() { - widget.costController.removeListener(_updateConvertedAmount); - super.dispose(); - } - - @override - void didUpdateWidget(ExchangeRateWidget oldWidget) { - super.didUpdateWidget(oldWidget); - - // 통화 변경 감지(달러->원화 또는 원화->달러)되면 리스너 해제 및 재등록 - if (oldWidget.showExchangeRate != widget.showExchangeRate) { - oldWidget.costController.removeListener(_updateConvertedAmount); - - if (widget.showExchangeRate) { - widget.costController.addListener(_updateConvertedAmount); - _loadExchangeRate(); - _updateConvertedAmount(); - } else { - setState(() { - _exchangeRateInfo = ''; - _convertedAmount = ''; - }); - } - } - } - - /// 환율 정보 로드 - Future _loadExchangeRate() async { - if (!widget.showExchangeRate) return; - - final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo(); - if (mounted) { - setState(() { - _exchangeRateInfo = rateInfo; - }); - } - } - - /// 달러 금액이 변경될 때 원화 금액 업데이트 - Future _updateConvertedAmount() async { - if (!widget.showExchangeRate) return; - - try { - // 금액 입력값에서 콤마 제거 후 숫자로 변환 - final text = widget.costController.text.replaceAll(',', ''); - if (text.isEmpty) { - setState(() { - _convertedAmount = ''; - }); - return; - } - - final amount = double.tryParse(text); - if (amount != null) { - final converted = - await _exchangeRateService.getFormattedKrwAmount(amount); - if (mounted) { - setState(() { - _convertedAmount = converted; - }); - } - } - } catch (e) { - // 오류 발생 시 빈 문자열 표시 - setState(() { - _convertedAmount = ''; - }); - } - } - - /// 환율 정보 텍스트 위젯 생성 - Widget buildExchangeRateInfo() { - if (_exchangeRateInfo.isEmpty) return const SizedBox.shrink(); - - return Text( - _exchangeRateInfo, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ); - } - - /// 환산 금액 텍스트 위젯 생성 - Widget buildConvertedAmount() { - if (_convertedAmount.isEmpty) return const SizedBox.shrink(); - - return Text( - _convertedAmount, - style: const TextStyle( - fontSize: 14, - color: Colors.blue, - fontWeight: FontWeight.w500, - ), - ); - } - - @override - Widget build(BuildContext context) { - if (!widget.showExchangeRate) { - return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환 - } - - return const Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성 - ], - ); - } - - // 익스포즈드 메서드: 환율 정보 문자열 가져오기 - String get exchangeRateInfo => _exchangeRateInfo; - - // 익스포즈드 메서드: 변환된 금액 문자열 가져오기 - String get convertedAmount => _convertedAmount; -} diff --git a/lib/widgets/expandable_fab.dart b/lib/widgets/expandable_fab.dart deleted file mode 100644 index 18183ed..0000000 --- a/lib/widgets/expandable_fab.dart +++ /dev/null @@ -1,269 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:math' as math; -import '../theme/app_colors.dart'; -import '../utils/haptic_feedback_helper.dart'; -import 'glassmorphism_card.dart'; - -class ExpandableFab extends StatefulWidget { - final List actions; - final double distance; - - const ExpandableFab({ - super.key, - required this.actions, - this.distance = 100.0, - }); - - @override - State createState() => _ExpandableFabState(); -} - -class _ExpandableFabState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _expandAnimation; - late Animation _rotateAnimation; - bool _isExpanded = false; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - _expandAnimation = CurvedAnimation( - parent: _controller, - curve: Curves.easeOutBack, - reverseCurve: Curves.easeInBack, - ); - - _rotateAnimation = Tween( - begin: 0.0, - end: math.pi / 4, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - )); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _toggle() { - setState(() { - _isExpanded = !_isExpanded; - }); - - if (_isExpanded) { - HapticFeedbackHelper.mediumImpact(); - _controller.forward(); - } else { - HapticFeedbackHelper.lightImpact(); - _controller.reverse(); - } - } - - @override - Widget build(BuildContext context) { - return Stack( - alignment: Alignment.bottomRight, - children: [ - // 배경 오버레이 (확장 시) - if (_isExpanded) - GestureDetector( - onTap: _toggle, - child: AnimatedBuilder( - animation: _expandAnimation, - builder: (context, child) { - return Container( - color: AppColors.shadowBlack - .withValues(alpha: 3.75 * _expandAnimation.value), - ); - }, - ), - ), - - // 액션 버튼들 - ...widget.actions.asMap().entries.map((entry) { - final index = entry.key; - final action = entry.value; - final angle = (index + 1) * (math.pi / 2 / widget.actions.length); - - return AnimatedBuilder( - animation: _expandAnimation, - builder: (context, child) { - final distance = widget.distance * _expandAnimation.value; - final x = distance * math.cos(angle); - final y = distance * math.sin(angle); - - return Transform.translate( - offset: Offset(-x, -y), - child: ScaleTransition( - scale: _expandAnimation, - child: FloatingActionButton.small( - heroTag: 'fab_action_$index', - onPressed: _isExpanded - ? () { - HapticFeedbackHelper.lightImpact(); - _toggle(); - action.onPressed(); - } - : null, - backgroundColor: action.color ?? AppColors.primaryColor, - child: Icon( - action.icon, - size: 20, - color: AppColors.pureWhite, - ), - ), - ), - ); - }, - ); - }), - - // 메인 FAB - AnimatedBuilder( - animation: _rotateAnimation, - builder: (context, child) { - return Transform.rotate( - angle: _rotateAnimation.value, - child: FloatingActionButton( - onPressed: _toggle, - backgroundColor: AppColors.primaryColor, - child: Icon( - _isExpanded ? Icons.close : Icons.add, - size: 28, - color: Colors.white, - ), - ), - ); - }, - ), - - // 라벨 표시 - if (_isExpanded) - ...widget.actions.asMap().entries.map((entry) { - final index = entry.key; - final action = entry.value; - final angle = (index + 1) * (math.pi / 2 / widget.actions.length); - - return AnimatedBuilder( - animation: _expandAnimation, - builder: (context, child) { - final distance = widget.distance * _expandAnimation.value; - final x = distance * math.cos(angle); - final y = distance * math.sin(angle); - - return Transform.translate( - offset: Offset(-x - 80, -y), - child: FadeTransition( - opacity: _expandAnimation, - child: GlassmorphismCard( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - borderRadius: 8, - blur: 10, - child: Text( - action.label, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.darkNavy, - ), - ), - ), - ), - ); - }, - ); - }), - ], - ); - } -} - -class FabAction { - final IconData icon; - final String label; - final VoidCallback onPressed; - final Color? color; - - const FabAction({ - required this.icon, - required this.label, - required this.onPressed, - this.color, - }); -} - -// 드래그 가능한 FAB -class DraggableFab extends StatefulWidget { - final Widget child; - final EdgeInsets? padding; - - const DraggableFab({ - super.key, - required this.child, - this.padding, - }); - - @override - State createState() => _DraggableFabState(); -} - -class _DraggableFabState extends State { - Offset _position = const Offset(20, 20); - bool _isDragging = false; - - @override - Widget build(BuildContext context) { - final screenSize = MediaQuery.of(context).size; - final padding = widget.padding ?? const EdgeInsets.all(20); - - return Stack( - children: [ - Positioned( - right: _position.dx, - bottom: _position.dy, - child: GestureDetector( - onPanStart: (_) { - setState(() => _isDragging = true); - HapticFeedbackHelper.lightImpact(); - }, - onPanUpdate: (details) { - setState(() { - _position = Offset( - (_position.dx - details.delta.dx).clamp( - padding.right, - screenSize.width - 100 - padding.left, - ), - (_position.dy - details.delta.dy).clamp( - padding.bottom, - screenSize.height - 200 - padding.top, - ), - ); - }); - }, - onPanEnd: (_) { - setState(() => _isDragging = false); - HapticFeedbackHelper.lightImpact(); - }, - child: AnimatedScale( - duration: const Duration(milliseconds: 150), - scale: _isDragging ? 0.9 : 1.0, - child: widget.child, - ), - ), - ), - ], - ); - } -} diff --git a/lib/widgets/glassmorphic_app_bar.dart b/lib/widgets/glassmorphic_app_bar.dart deleted file mode 100644 index 6509575..0000000 --- a/lib/widgets/glassmorphic_app_bar.dart +++ /dev/null @@ -1,320 +0,0 @@ -import 'package:flutter/material.dart'; -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 { - final String title; - final List? actions; - final Widget? leading; - final bool automaticallyImplyLeading; - final double elevation; - final Color? backgroundColor; - final double blur; - final double opacity; - final PreferredSizeWidget? bottom; - final bool centerTitle; - final double? titleSpacing; - final VoidCallback? onBackPressed; - - const GlassmorphicAppBar({ - super.key, - required this.title, - this.actions, - this.leading, - this.automaticallyImplyLeading = true, - this.elevation = 0, - this.backgroundColor, - this.blur = 20, - this.opacity = 0.1, - this.bottom, - this.centerTitle = false, - this.titleSpacing, - this.onBackPressed, - }); - - @override - Size get preferredSize => Size.fromHeight( - kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final canPop = Navigator.of(context).canPop(); - - return ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - (backgroundColor ?? - (isDarkMode - ? AppColors.glassBackgroundDark - : AppColors.glassBackground)) - .withValues(alpha: opacity), - (backgroundColor ?? - (isDarkMode - ? AppColors.glassSurfaceDark - : AppColors.glassSurface)) - .withValues(alpha: opacity * 0.8), - ], - ), - border: Border( - bottom: BorderSide( - color: isDarkMode - ? AppColors.primaryColor.withValues(alpha: 0.3) - : AppColors.glassBorder.withValues(alpha: 0.5), - width: 0.5, - ), - ), - ), - child: SafeArea( - bottom: false, - child: ClipRect( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: SizedBox( - height: kToolbarHeight, - child: NavigationToolbar( - leading: leading ?? - (automaticallyImplyLeading && - (canPop || onBackPressed != null) - ? _buildBackButton(context) - : null), - middle: _buildTitle(context), - trailing: actions != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: actions!, - ) - : null, - centerMiddle: centerTitle, - middleSpacing: - titleSpacing ?? NavigationToolbar.kMiddleSpacing, - ), - ), - ), - if (bottom != null) bottom!, - ], - ), - ), - ), - ), - ), - ); - } - - Widget _buildBackButton(BuildContext context) { - return IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: onBackPressed ?? - () { - HapticFeedback.lightImpact(); - Navigator.of(context).pop(); - }, - splashRadius: 24, - tooltip: AppLocalizations.of(context).back, - color: ThemedText.getContrastColor(context), - ); - } - - Widget _buildTitle(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ThemedText.headline( - text: title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - ), - ), - ); - } - - /// 투명 스타일 팩토리 - static GlassmorphicAppBar transparent({ - required String title, - List? actions, - Widget? leading, - VoidCallback? onBackPressed, - }) { - return GlassmorphicAppBar( - title: title, - actions: actions, - leading: leading, - blur: 30, - opacity: 0.05, - onBackPressed: onBackPressed, - ); - } - - /// 반투명 스타일 팩토리 - static GlassmorphicAppBar translucent({ - required String title, - List? actions, - Widget? leading, - VoidCallback? onBackPressed, - }) { - return GlassmorphicAppBar( - title: title, - actions: actions, - leading: leading, - blur: 20, - opacity: 0.15, - onBackPressed: onBackPressed, - ); - } -} - -/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함) -class GlassmorphicSliverAppBar extends StatelessWidget { - final String title; - final List? actions; - final Widget? leading; - final double expandedHeight; - final bool floating; - final bool pinned; - final bool snap; - final Widget? flexibleSpace; - final double blur; - final double opacity; - final bool automaticallyImplyLeading; - final VoidCallback? onBackPressed; - final bool centerTitle; - - const GlassmorphicSliverAppBar({ - super.key, - required this.title, - this.actions, - this.leading, - this.expandedHeight = kToolbarHeight, - this.floating = false, - this.pinned = true, - this.snap = false, - this.flexibleSpace, - this.blur = 20, - this.opacity = 0.1, - this.automaticallyImplyLeading = true, - this.onBackPressed, - this.centerTitle = false, - }); - - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final canPop = Navigator.of(context).canPop(); - - return SliverAppBar( - expandedHeight: expandedHeight, - floating: floating, - pinned: pinned, - snap: snap, - backgroundColor: Colors.transparent, - elevation: 0, - automaticallyImplyLeading: false, - leading: leading ?? - (automaticallyImplyLeading && (canPop || onBackPressed != null) - ? IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: onBackPressed ?? - () { - HapticFeedback.lightImpact(); - Navigator.of(context).pop(); - }, - splashRadius: 24, - tooltip: AppLocalizations.of(context).back, - ) - : null), - actions: actions, - centerTitle: centerTitle, - flexibleSpace: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final top = constraints.biggest.height; - final isCollapsed = - top <= kToolbarHeight + MediaQuery.of(context).padding.top; - - return FlexibleSpaceBar( - title: isCollapsed - ? ThemedText.headline( - text: title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, - ), - ) - : null, - centerTitle: centerTitle, - titlePadding: - const EdgeInsets.only(left: 16, bottom: 16, right: 16), - background: Stack( - fit: StackFit.expand, - children: [ - // 글래스모피즘 배경 - ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - (isDarkMode - ? AppColors.glassBackgroundDark - : AppColors.glassBackground) - .withValues(alpha: opacity), - (isDarkMode - ? AppColors.glassSurfaceDark - : AppColors.glassSurface) - .withValues(alpha: opacity * 0.8), - ], - ), - border: Border( - bottom: BorderSide( - color: isDarkMode - ? AppColors.primaryColor.withValues(alpha: 0.3) - : AppColors.glassBorder.withValues(alpha: 0.5), - width: 0.5, - ), - ), - ), - ), - ), - ), - // 확장 상태에서만 보이는 타이틀 - if (!isCollapsed) - Positioned( - left: 16, - right: 16, - bottom: 16, - child: ThemedText.headline( - text: title, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - ), - ), - ), - // 커스텀 flexibleSpace가 있으면 추가 - if (flexibleSpace != null) flexibleSpace!, - ], - ), - ); - }, - ), - ); - } -} diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index 49a8be8..b35822a 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -42,315 +42,321 @@ class MainScreenSummaryCard extends StatelessWidget { CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), child: Padding( padding: const EdgeInsets.fromLTRB(16, 23, 16, 12), - child: GlassmorphismCard( - borderRadius: 16, - blur: 15, - backgroundColor: AppColors.glassCard, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: AppColors.mainGradient - .map((color) => color.withValues(alpha: 0.2)) - .toList(), - ), - border: Border.all( - color: AppColors.glassBorder, - width: 1, - ), - child: Container( - width: double.infinity, - constraints: BoxConstraints( - minHeight: 180, - maxHeight: activeEvents > 0 ? 300 : 240, + child: RepaintBoundary( + child: GlassmorphismCard( + borderRadius: 16, + blur: 15, + backgroundColor: AppColors.glassCard, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppColors.mainGradient + .map((color) => color.withValues(alpha: 0.2)) + .toList(), ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Colors.transparent, + border: Border.all( + color: AppColors.glassBorder, + width: 1, ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Stack( - children: [ - // 애니메이션 웨이브 배경 - Positioned.fill( - child: AnimatedWaveBackground( - controller: waveController, - pulseController: pulseController, + child: Container( + width: double.infinity, + constraints: BoxConstraints( + minHeight: 180, + maxHeight: activeEvents > 0 ? 300 : 240, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Colors.transparent, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Stack( + children: [ + // 애니메이션 웨이브 배경 + Positioned.fill( + child: AnimatedWaveBackground( + controller: waveController, + pulseController: pulseController, + ), ), - ), - Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - AppLocalizations.of(context) - .monthlyTotalSubscriptionCost, - style: const TextStyle( - color: AppColors - .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 15, - fontWeight: FontWeight.w500, + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of(context) + .monthlyTotalSubscriptionCost, + style: const TextStyle( + color: AppColors + .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + fontSize: 15, + fontWeight: FontWeight.w500, + ), ), + // 환율 정보 표시 (영어 사용자는 표시 안함) + 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, + ), + decoration: BoxDecoration( + color: const Color(0xFFE5F2FF), + borderRadius: + BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFFBFDBFE), + width: 1, + ), + ), + 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), + // 월별 총 비용 표시 (언어별 기본 통화) + FutureBuilder( + future: CurrencyUtil + .calculateTotalMonthlyExpenseInDefaultCurrency( + provider.subscriptions, + locale, ), - // 환율 정보 표시 (영어 사용자는 표시 안함) - 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, - ), - decoration: BoxDecoration( - color: const Color(0xFFE5F2FF), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: const Color(0xFFBFDBFE), - width: 1, - ), - ), - 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), - // 월별 총 비용 표시 (언어별 기본 통화) - 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; + 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, + 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(width: 4), + Text( + currencySymbol, + style: const TextStyle( + color: AppColors.darkNavy, + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), - ), - ], - ); - }, - ), - const SizedBox(height: 16), - // 연간 비용 및 총 구독 수 표시 - 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) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 14), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white.withValues(alpha: 0.2), - Colors.white.withValues(alpha: 0.15), ], - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.primaryColor - .withValues(alpha: 0.3), - width: 1, - ), + ); + }, + ), + const SizedBox(height: 16), + // 연간 비용 및 총 구독 수 표시 + FutureBuilder( + future: CurrencyUtil + .calculateTotalMonthlyExpenseInDefaultCurrency( + provider.subscriptions, + locale, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.25), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.local_offer_rounded, - size: 14, - color: AppColors - .primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘 - ), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context) - .eventDiscountActive, - style: const TextStyle( - color: AppColors - .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - // 이벤트 절약액 표시 (언어별 기본 통화) - 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; + 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: [ - 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, - ), - ), - ], - ); - }, - ), + 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) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + vertical: 10, horizontal: 14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0.2), + Colors.white.withValues(alpha: 0.15), ], ), - ], + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primaryColor + .withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: + Colors.white.withValues(alpha: 0.25), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.local_offer_rounded, + size: 14, + color: AppColors + .primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘 + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context) + .eventDiscountActive, + style: const TextStyle( + color: AppColors + .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + // 이벤트 절약액 표시 (언어별 기본 통화) + 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/skeleton_loading.dart b/lib/widgets/skeleton_loading.dart deleted file mode 100644 index 90e8dba..0000000 --- a/lib/widgets/skeleton_loading.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:flutter/material.dart'; -import 'glassmorphism_card.dart'; - -class SkeletonLoading extends StatelessWidget { - final double? width; - final double? height; - final double borderRadius; - - const SkeletonLoading({ - Key? key, - this.width, - this.height, - this.borderRadius = 8.0, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - // 단일 스켈레톤 아이템이 요청된 경우 - if (width != null || height != null) { - return _buildSingleSkeleton(); - } - - // 기본 전체 화면 스켈레톤 - return Column( - children: [ - // 요약 카드 스켈레톤 - GlassmorphismCard( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(16.0), - blur: 10, - opacity: 0.1, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 100, - height: 24, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildSkeletonColumn(), - _buildSkeletonColumn(), - ], - ), - ], - ), - ), - // 구독 목록 스켈레톤 - Expanded( - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) { - return GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), - blur: 10, - opacity: 0.1, - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 200, - height: 24, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 8), - Container( - width: 150, - height: 16, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 4), - Container( - width: 180, - height: 16, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ), - ), - ], - ); - } - - Widget _buildSingleSkeleton() { - return Container( - width: width, - height: height, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(borderRadius), - ), - child: AnimatedContainer( - duration: const Duration(milliseconds: 1500), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.grey[300]!, - Colors.grey[100]!, - Colors.grey[300]!, - ], - ), - ), - ), - ); - } - - Widget _buildSkeletonColumn() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 80, - height: 16, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 4), - Container( - width: 100, - height: 24, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ); - } -} diff --git a/lib/widgets/spring_animation_widget.dart b/lib/widgets/spring_animation_widget.dart deleted file mode 100644 index 8876787..0000000 --- a/lib/widgets/spring_animation_widget.dart +++ /dev/null @@ -1,340 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 물리 기반 스프링 애니메이션을 적용하는 위젯 -class SpringAnimationWidget extends StatefulWidget { - final Widget child; - final Duration delay; - final SpringDescription spring; - final Offset? initialOffset; - final double? initialScale; - final double? initialRotation; - - const SpringAnimationWidget({ - super.key, - required this.child, - this.delay = Duration.zero, - this.spring = const SpringDescription( - mass: 1, - stiffness: 100, - damping: 10, - ), - this.initialOffset, - this.initialScale, - this.initialRotation, - }); - - @override - State createState() => _SpringAnimationWidgetState(); -} - -class _SpringAnimationWidgetState extends State - with TickerProviderStateMixin { - late AnimationController _controller; - late Animation _offsetAnimation; - late Animation _scaleAnimation; - late Animation _rotationAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 2), - ); - - // 오프셋 애니메이션 - _offsetAnimation = Tween( - begin: widget.initialOffset ?? const Offset(0, 50), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.elasticOut, - )); - - // 스케일 애니메이션 - _scaleAnimation = Tween( - begin: widget.initialScale ?? 0.5, - end: 1.0, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.elasticOut, - )); - - // 회전 애니메이션 - _rotationAnimation = Tween( - begin: widget.initialRotation ?? 0.0, - end: 0.0, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.elasticOut, - )); - - // 지연 후 애니메이션 시작 - Future.delayed(widget.delay, () { - if (mounted) { - _controller.forward(); - } - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return Transform.translate( - offset: _offsetAnimation.value, - child: Transform.scale( - scale: _scaleAnimation.value, - child: Transform.rotate( - angle: _rotationAnimation.value, - child: child, - ), - ), - ); - }, - child: widget.child, - ); - } -} - -/// 바운스 효과가 있는 버튼 -class BouncyButton extends StatefulWidget { - final Widget child; - final VoidCallback? onPressed; - final EdgeInsetsGeometry? padding; - final BoxDecoration? decoration; - - const BouncyButton({ - super.key, - required this.child, - this.onPressed, - this.padding, - this.decoration, - }); - - @override - State createState() => _BouncyButtonState(); -} - -class _BouncyButtonState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 200), - vsync: this, - ); - - _scaleAnimation = Tween( - begin: 1.0, - end: 0.95, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeInOut, - )); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _handleTapDown(TapDownDetails details) { - _controller.forward(); - } - - void _handleTapUp(TapUpDetails details) { - _controller.reverse(); - widget.onPressed?.call(); - } - - void _handleTapCancel() { - _controller.reverse(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTapDown: _handleTapDown, - onTapUp: _handleTapUp, - onTapCancel: _handleTapCancel, - child: AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) { - return Transform.scale( - scale: _scaleAnimation.value, - child: Container( - padding: widget.padding, - decoration: widget.decoration, - child: widget.child, - ), - ); - }, - ), - ); - } -} - -/// 중력 효과 애니메이션 -class GravityAnimation extends StatefulWidget { - final Widget child; - final double gravity; - final double bounceFactor; - final double initialVelocity; - - const GravityAnimation({ - super.key, - required this.child, - this.gravity = 9.8, - this.bounceFactor = 0.8, - this.initialVelocity = 0, - }); - - @override - State createState() => _GravityAnimationState(); -} - -class _GravityAnimationState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - double _position = 0; - double _velocity = 0; - final double _floor = 300; - - @override - void initState() { - super.initState(); - _velocity = widget.initialVelocity; - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 10), - )..addListener(_updatePhysics); - - _controller.repeat(); - } - - void _updatePhysics() { - setState(() { - // 속도 업데이트 (중력 적용) - _velocity += widget.gravity * 0.016; // 60fps 가정 - - // 위치 업데이트 - _position += _velocity; - - // 바닥 충돌 감지 - if (_position >= _floor) { - _position = _floor; - _velocity = -_velocity * widget.bounceFactor; - - // 너무 작은 바운스는 멈춤 - if (_velocity.abs() < 1) { - _velocity = 0; - } - } - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Transform.translate( - offset: Offset(0, _position), - child: widget.child, - ); - } -} - -/// 물결 효과 애니메이션 -class RippleAnimation extends StatefulWidget { - final Widget child; - final Color rippleColor; - final Duration duration; - - const RippleAnimation({ - super.key, - required this.child, - this.rippleColor = Colors.blue, - this.duration = const Duration(milliseconds: 600), - }); - - @override - State createState() => _RippleAnimationState(); -} - -class _RippleAnimationState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: widget.duration, - vsync: this, - ); - - _animation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _controller, - curve: Curves.easeOut, - )); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _handleTap() { - _controller.forward(from: 0.0); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: _handleTap, - child: Stack( - alignment: Alignment.center, - children: [ - AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Container( - width: 100 + 200 * _animation.value, - height: 100 + 200 * _animation.value, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.rippleColor.withValues( - alpha: (1 - _animation.value) * 0.3, - ), - ), - ); - }, - ), - widget.child, - ], - ), - ); - } -}