refactor: remove unreferenced widgets/utilities and backup file in lib

This commit is contained in:
JiWoong Sul
2025-09-08 14:33:55 +09:00
parent 10069a1800
commit 5a7ef8039e
11 changed files with 299 additions and 3155 deletions

View File

@@ -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<String, dynamic>? _servicesData;
static bool _isInitialized = false;
// 레거시 데이터 (JSON 로드 실패시 폴백)
// OTT 서비스
static final Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> allServices = {
...ottServices,
...musicServices,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
/// JSON 데이터 초기화
static Future<void> 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<ServiceInfo?> 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<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceEntry in services.entries) {
final serviceId = serviceEntry.key;
final serviceData = serviceEntry.value as Map<String, dynamic>;
final domains = List<String>.from(serviceData['domains'] ?? []);
// 도메인이 일치하는지 확인
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
final names = List<String>.from(serviceData['names'] ?? []);
final urls = serviceData['urls'] as Map<String, dynamic>?;
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<String?> findCancellationUrl({
String? serviceName,
String? websiteUrl,
String locale = 'kr',
}) async {
await initialize();
// JSON 데이터가 있으면 JSON에서 찾기
if (_servicesData != null) {
final categories = _servicesData!['categories'] as Map<String, dynamic>;
// 1. 서비스명으로 찾기
if (serviceName != null && serviceName.isNotEmpty) {
final lowerName = serviceName.toLowerCase().trim();
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
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<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
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<bool> hasCancellationPage(String serviceNameOrUrl) async {
// 새로운 JSON 기반 방식으로 확인
final cancellationUrl = await findCancellationUrl(
serviceName: serviceNameOrUrl,
websiteUrl: serviceNameOrUrl,
);
return cancellationUrl != null;
}
/// 서비스명으로 카테고리 찾기
static Future<String?> 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<String, dynamic>;
for (final categoryEntry in categories.entries) {
final categoryId = categoryEntry.key;
final categoryData = categoryEntry.value as Map<String, dynamic>;
final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
return _getCategoryIdByKey(categoryId);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
return _getCategoryForLegacyService(serviceName);
}
/// 현재 로케일에 따라 서비스 표시명 가져오기
static Future<String> getServiceDisplayName({
required String serviceName,
required String locale,
}) async {
await initialize();
if (_servicesData == null) {
return serviceName;
}
final lowerName = serviceName.toLowerCase().trim();
final categories = _servicesData!['categories'] as Map<String, dynamic>;
// JSON에서 서비스 찾기
for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
for (final serviceData in services.values) {
final data = serviceData as Map<String, dynamic>;
final names = List<String>.from(data['names'] ?? []);
// names 배열에 있는지 확인
for (final name in names) {
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
// 로케일에 따라 적절한 이름 반환
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
// nameKr/nameEn에 직접 매칭 확인
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
if (lowerName == nameKr || lowerName == nameEn) {
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
} else {
return data['nameEn'] ?? serviceName;
}
}
}
}
// 찾지 못한 경우 원래 이름 반환
return serviceName;
}
/// 카테고리 키를 실제 카테고리 ID로 매핑
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<ServiceInfo?> 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<bool> 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;
}
}

View File

@@ -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일';
}
}
}

View File

@@ -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<DangerButton> createState() => _DangerButtonState();
}
class _DangerButtonState extends State<DangerButton> {
bool _isHovered = false;
static const Color _dangerColor = AppColors.dangerColor;
Future<void> _handlePress() async {
if (widget.requireConfirmation) {
final confirmed = await showDialog<bool>(
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;
}
}

View File

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

View File

@@ -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<void> 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<CustomLoadingIndicator> createState() => _CustomLoadingIndicatorState();
}
class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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,
}

View File

@@ -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<ExchangeRateWidget> createState() => _ExchangeRateWidgetState();
}
class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
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<void> _loadExchangeRate() async {
if (!widget.showExchangeRate) return;
final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo();
if (mounted) {
setState(() {
_exchangeRateInfo = rateInfo;
});
}
}
/// 달러 금액이 변경될 때 원화 금액 업데이트
Future<void> _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;
}

View File

@@ -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<FabAction> actions;
final double distance;
const ExpandableFab({
super.key,
required this.actions,
this.distance = 100.0,
});
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _expandAnimation;
late Animation<double> _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<double>(
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<DraggableFab> createState() => _DraggableFabState();
}
class _DraggableFabState extends State<DraggableFab> {
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,
),
),
),
],
);
}
}

View File

@@ -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<Widget>? 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<Widget>? 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<Widget>? 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<Widget>? 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!,
],
),
);
},
),
);
}
}

View File

@@ -42,6 +42,7 @@ class MainScreenSummaryCard extends StatelessWidget {
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12), padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
child: RepaintBoundary(
child: GlassmorphismCard( child: GlassmorphismCard(
borderRadius: 16, borderRadius: 16,
blur: 15, blur: 15,
@@ -113,7 +114,8 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE5F2FF), color: const Color(0xFFE5F2FF),
borderRadius: BorderRadius.circular(4), borderRadius:
BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: const Color(0xFFBFDBFE), color: const Color(0xFFBFDBFE),
width: 1, width: 1,
@@ -265,7 +267,8 @@ class MainScreenSummaryCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25), color:
Colors.white.withValues(alpha: 0.25),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
@@ -277,7 +280,8 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
AppLocalizations.of(context) AppLocalizations.of(context)
@@ -312,7 +316,8 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [ children: [
Text( Text(
NumberFormat.currency( NumberFormat.currency(
locale: defaultCurrency == 'KRW' locale: defaultCurrency ==
'KRW'
? 'ko_KR' ? 'ko_KR'
: defaultCurrency == 'JPY' : defaultCurrency == 'JPY'
? 'ja_JP' ? 'ja_JP'
@@ -356,6 +361,7 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }

View File

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

View File

@@ -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<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
}
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
// 오프셋 애니메이션
_offsetAnimation = Tween<Offset>(
begin: widget.initialOffset ?? const Offset(0, 50),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 스케일 애니메이션
_scaleAnimation = Tween<double>(
begin: widget.initialScale ?? 0.5,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 회전 애니메이션
_rotationAnimation = Tween<double>(
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<BouncyButton> createState() => _BouncyButtonState();
}
class _BouncyButtonState extends State<BouncyButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
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<GravityAnimation> createState() => _GravityAnimationState();
}
class _GravityAnimationState extends State<GravityAnimation>
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<RippleAnimation> createState() => _RippleAnimationState();
}
class _RippleAnimationState extends State<RippleAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(
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,
],
),
);
}
}