diff --git a/.claude/agents/codex.md b/.claude/agents/codex.md new file mode 100644 index 0000000..4191438 --- /dev/null +++ b/.claude/agents/codex.md @@ -0,0 +1,13 @@ +# Project Agent Handoff + +Use AGENTS.md at repo root as the source of truth for coding rules and guardrails. + +Key Rules +- Code first, concise rationale after. If uncertain, say "Uncertain:". +- Keep diffs minimal; follow existing patterns and `analysis_options.yaml`. +- Validate with `scripts/check.sh` (format/analyze/test) before completion. +- Ask for approval before dependency changes, build config edits, or network access. + +Templates +- Task and PR templates are in `AGENTS.md` and `doc/agents/codex_prompt_templates.md`. + diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml new file mode 100644 index 0000000..06e55df --- /dev/null +++ b/.github/workflows/flutter_ci.yml @@ -0,0 +1,31 @@ +name: Flutter CI + +on: + pull_request: + push: + branches: [ main, master ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Flutter Pub Get + run: flutter pub get + + - name: Format check + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze + run: flutter analyze + + - name: Test + run: flutter test + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2be2010 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +Codex Agent Guide for SubManager + +Scope +- Applies to the entire repository unless a more specific rule exists deeper in the tree. +- Precedence: project AGENTS.md > project .claude/agents > user ~/.claude > default Codex CLI rules. Direct system/developer instructions always win. + +Goals +- Accelerate small, safe changes with consistent quality. +- Keep diffs minimal, focused, and aligned with Flutter best practices. + +Guardrails +- Workspace only: modify files within this repo. Ask before adding dependencies or using network. +- Safety: avoid destructive actions (file deletions, rewrites, config changes) unless explicitly requested. +- Responses: be concise; code first, short rationale after. If uncertain, prefix with "Uncertain:". If multiple viable solutions, show the top 2 briefly. +- Planning: for multi‑step tasks, maintain an update_plan with exactly one in_progress step. +- Language: 기본적으로 한국어로 응답합니다. (필요 시 코드/로그/명령어는 원문 유지) + +Coding Standards +- Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline). +- Style/format: use `dart format .` and keep changes minimal. Avoid one‑letter variable names; avoid inline comments unless requested. +- Structure: follow existing file/module patterns and naming. Do not introduce new frameworks or architectural shifts without approval. +- Tests: add or update tests when behavior changes or bugs are fixed (if feasible). Keep tests scoped to the change. + +Validation +- Always run local checks via `scripts/check.sh` before proposing completion: + - formatting check: `dart format --set-exit-if-changed .` + - static analysis: `flutter analyze` + - unit/widget tests: `flutter test` (ok if none exist) +- UI changes: include brief description of visual impact; screenshots if readily available by the user. + +Sensitive Areas (require explicit approval) +- Android/iOS/macOS build configs, signing, bundle identifiers, Gradle/Kotlin/Swift project settings. +- Dependency graph changes (pubspec.yaml add/remove/upgrade). +- Network access, calling external APIs, or adding secrets. + +Operational Conventions +- Branch naming: `codex/-` (e.g., `codex/fix-url-matcher`). +- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`). +- PR description template: + - Summary: what/why + - Changes: key files and decisions + - Validation: how verified (analyze/tests/manual) + - Risk & Rollback: potential impact and quick rollback steps + +Task Template (author-provided) +--- +Next: +Complexity: simple | medium | complex + +Context +- Problem / goal: +- Constraints / non‑goals: +- Repro or commands: + +Done When +- [ ] Behavior verified (`scripts/check.sh` passes) +- [ ] Tests/docs updated if applicable +--- + +Commands +- Lint/analyze/tests: `scripts/check.sh` +- Auto‑format: `scripts/fix.sh` + +References & External Facts +- Prefer official docs and code‑local references. If citing sources, include plain URLs or file paths in PR descriptions (avoid footnote citation syntaxes). + +Notes from ~/.claude (adapted) +- Few‑shot examples improve accuracy; include small before/after or sample input→output when helpful. +- Use structured thinking internally; present only concise, actionable outputs here. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 797fa90..c6eaf9c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + + + GADApplicationIdentifier + ca-app-pub-6691216385521068~6638409932 diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart index 5f925d5..12c6d8f 100644 --- a/lib/controllers/add_subscription_controller.dart +++ b/lib/controllers/add_subscription_controller.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; -import '../models/subscription_model.dart'; import '../providers/subscription_provider.dart'; import '../providers/category_provider.dart'; import '../services/sms_service.dart'; @@ -13,29 +12,29 @@ import '../l10n/app_localizations.dart'; /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller class AddSubscriptionController { final BuildContext context; - + // Form Key final formKey = GlobalKey(); - + // Text Controllers final serviceNameController = TextEditingController(); final monthlyCostController = TextEditingController(); final nextBillingDateController = TextEditingController(); final websiteUrlController = TextEditingController(); final eventPriceController = TextEditingController(); - + // Form State String billingCycle = 'monthly'; String currency = 'KRW'; DateTime? nextBillingDate; bool isLoading = false; String? selectedCategoryId; - + // Event State bool isEventActive = false; DateTime? eventStartDate = DateTime.now(); DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30)); - + // Focus Nodes final serviceNameFocus = FocusNode(); final monthlyCostFocus = FocusNode(); @@ -44,20 +43,20 @@ class AddSubscriptionController { final websiteUrlFocus = FocusNode(); final categoryFocus = FocusNode(); final currencyFocus = FocusNode(); - + // Animation Controller AnimationController? animationController; Animation? fadeAnimation; Animation? slideAnimation; - + // Scroll Controller final ScrollController scrollController = ScrollController(); double scrollOffset = 0; - + // UI State int currentEditingField = -1; bool isSaveHovered = false; - + // Gradient Colors final List gradientColors = [ const Color(0xFF3B82F6), @@ -71,19 +70,19 @@ class AddSubscriptionController { void initialize({required TickerProvider vsync}) { // 결제일 기본값을 오늘 날짜로 설정 nextBillingDate = DateTime.now(); - + // 서비스명 컨트롤러에 리스너 추가 serviceNameController.addListener(onServiceNameChanged); - + // 웹사이트 URL 컨트롤러에 리스너 추가 websiteUrlController.addListener(onWebsiteUrlChanged); - + // 애니메이션 컨트롤러 초기화 animationController = AnimationController( vsync: vsync, duration: const Duration(milliseconds: 800), ); - + fadeAnimation = Tween( begin: 0.0, end: 1.0, @@ -91,7 +90,7 @@ class AddSubscriptionController { parent: animationController!, curve: Curves.easeIn, )); - + slideAnimation = Tween( begin: const Offset(0.0, 0.2), end: Offset.zero, @@ -99,12 +98,12 @@ class AddSubscriptionController { parent: animationController!, curve: Curves.easeOut, )); - + // 스크롤 리스너 scrollController.addListener(() { scrollOffset = scrollController.offset; }); - + // 애니메이션 시작 animationController!.forward(); } @@ -117,7 +116,7 @@ class AddSubscriptionController { nextBillingDateController.dispose(); websiteUrlController.dispose(); eventPriceController.dispose(); - + // Focus Nodes serviceNameFocus.dispose(); monthlyCostFocus.dispose(); @@ -126,10 +125,10 @@ class AddSubscriptionController { websiteUrlFocus.dispose(); categoryFocus.dispose(); currencyFocus.dispose(); - + // Animation animationController?.dispose(); - + // Scroll scrollController.dispose(); } @@ -138,48 +137,52 @@ class AddSubscriptionController { void onServiceNameChanged() { autoSelectCategory(); } - + /// 웹사이트 URL 변경시 호출 void onWebsiteUrlChanged() async { final url = websiteUrlController.text.trim(); - + // URL이 비어있거나 너무 짧으면 무시 if (url.isEmpty || url.length < 5) return; - + // 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음 if (serviceNameController.text.isNotEmpty) return; - + try { // URL로 서비스 정보 찾기 final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url); - + if (serviceInfo != null && context.mounted) { // 서비스명 자동 입력 serviceNameController.text = serviceInfo.serviceName; - + // 카테고리 자동 선택 - final categoryProvider = Provider.of(context, listen: false); + final categoryProvider = + Provider.of(context, listen: false); final categories = categoryProvider.categories; - + // 카테고리 ID로 매칭 final matchedCategory = categories.firstWhere( - (cat) => cat.name == serviceInfo.categoryNameKr || - cat.name == serviceInfo.categoryNameEn, + (cat) => + cat.name == serviceInfo.categoryNameKr || + cat.name == serviceInfo.categoryNameEn, orElse: () => categories.first, ); - + selectedCategoryId = matchedCategory.id; - + // 스낵바로 알림 if (context.mounted) { AppSnackBar.showSuccess( context: context, - message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName), + message: AppLocalizations.of(context) + .serviceRecognized(serviceInfo.serviceName), ); } } } catch (e) { if (kDebugMode) { + // ignore: avoid_print print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e'); } } @@ -187,17 +190,18 @@ class AddSubscriptionController { /// 카테고리 자동 선택 void autoSelectCategory() { - final categoryProvider = Provider.of(context, listen: false); + final categoryProvider = + Provider.of(context, listen: false); final categories = categoryProvider.categories; - + final serviceName = serviceNameController.text.toLowerCase(); - + // 서비스명에 기반한 카테고리 매칭 로직 dynamic matchedCategory; - + // 엔터테인먼트 관련 키워드 - if (serviceName.contains('netflix') || - serviceName.contains('youtube') || + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || serviceName.contains('disney') || serviceName.contains('왓챠') || serviceName.contains('티빙') || @@ -210,64 +214,64 @@ class AddSubscriptionController { ); } // 음악 관련 키워드 - else if (serviceName.contains('spotify') || - serviceName.contains('apple music') || - serviceName.contains('멜론') || - serviceName.contains('지니') || - serviceName.contains('플로') || - serviceName.contains('벅스')) { + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('멜론') || + serviceName.contains('지니') || + serviceName.contains('플로') || + serviceName.contains('벅스')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'music', orElse: () => categories.first, ); } // 생산성 관련 키워드 - else if (serviceName.contains('notion') || - serviceName.contains('microsoft') || - serviceName.contains('office') || - serviceName.contains('google') || - serviceName.contains('dropbox') || - serviceName.contains('icloud') || - serviceName.contains('adobe')) { + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('google') || + serviceName.contains('dropbox') || + serviceName.contains('icloud') || + serviceName.contains('adobe')) { matchedCategory = categories.firstWhere( (cat) => cat.name == '생산성', orElse: () => categories.first, ); } // 게임 관련 키워드 - else if (serviceName.contains('xbox') || - serviceName.contains('playstation') || - serviceName.contains('nintendo') || - serviceName.contains('steam') || - serviceName.contains('게임')) { + else if (serviceName.contains('xbox') || + serviceName.contains('playstation') || + serviceName.contains('nintendo') || + serviceName.contains('steam') || + serviceName.contains('게임')) { matchedCategory = categories.firstWhere( (cat) => cat.name == '게임', orElse: () => categories.first, ); } // 교육 관련 키워드 - else if (serviceName.contains('coursera') || - serviceName.contains('udemy') || - serviceName.contains('인프런') || - serviceName.contains('패스트캠퍼스') || - serviceName.contains('클래스101')) { + else if (serviceName.contains('coursera') || + serviceName.contains('udemy') || + serviceName.contains('인프런') || + serviceName.contains('패스트캠퍼스') || + serviceName.contains('클래스101')) { matchedCategory = categories.firstWhere( (cat) => cat.name == '교육', orElse: () => categories.first, ); } // 쇼핑 관련 키워드 - else if (serviceName.contains('쿠팡') || - serviceName.contains('coupang') || - serviceName.contains('amazon') || - serviceName.contains('네이버') || - serviceName.contains('11번가')) { + else if (serviceName.contains('쿠팡') || + serviceName.contains('coupang') || + serviceName.contains('amazon') || + serviceName.contains('네이버') || + serviceName.contains('11번가')) { matchedCategory = categories.firstWhere( (cat) => cat.name == '쇼핑', orElse: () => categories.first, ); } - + if (matchedCategory != null) { selectedCategoryId = matchedCategory.id; } @@ -276,9 +280,9 @@ class AddSubscriptionController { /// SMS 스캔 Future scanSMS({required Function setState}) async { if (kIsWeb) return; - + setState(() => isLoading = true); - + try { if (!await SMSService.hasSMSPermission()) { final granted = await SMSService.requestSMSPermission(); @@ -292,7 +296,7 @@ class AddSubscriptionController { return; } } - + final subscriptions = await SMSService.scanSubscriptions(); if (subscriptions.isEmpty) { if (context.mounted) { @@ -303,48 +307,52 @@ class AddSubscriptionController { } return; } - + final subscription = subscriptions.first; - + // SMS에서 서비스 정보 추출 시도 ServiceInfo? serviceInfo; final smsContent = subscription['smsContent'] ?? ''; - + if (smsContent.isNotEmpty) { try { - serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); + serviceInfo = + await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); } catch (e) { if (kDebugMode) { + // ignore: avoid_print print('AddSubscriptionController: SMS 서비스 추출 실패 - $e'); } } } - + setState(() { // 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용 if (serviceInfo != null) { serviceNameController.text = serviceInfo.serviceName; websiteUrlController.text = serviceInfo.serviceUrl ?? ''; - + // 카테고리 자동 선택 - final categoryProvider = Provider.of(context, listen: false); + final categoryProvider = + Provider.of(context, listen: false); final categories = categoryProvider.categories; - + final matchedCategory = categories.firstWhere( - (cat) => cat.name == serviceInfo!.categoryNameKr || - cat.name == serviceInfo.categoryNameEn, + (cat) => + cat.name == serviceInfo!.categoryNameKr || + cat.name == serviceInfo.categoryNameEn, orElse: () => categories.first, ); - + selectedCategoryId = matchedCategory.id; } else { // 기존 로직 사용 serviceNameController.text = subscription['serviceName'] ?? ''; } - + // 비용 처리 및 통화 단위 자동 감지 final costValue = subscription['monthlyCost']?.toString() ?? ''; - + if (costValue.isNotEmpty) { // 달러 표시가 있거나 소수점이 있으면 달러로 판단 if (costValue.contains('\$') || costValue.contains('.')) { @@ -353,41 +361,41 @@ class AddSubscriptionController { if (!numericValue.contains('.')) { numericValue = '$numericValue.00'; } - final double parsedValue = + final double parsedValue = double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; - monthlyCostController.text = + monthlyCostController.text = NumberFormat('#,##0.00').format(parsedValue); } else { currency = 'KRW'; - String numericValue = + String numericValue = costValue.replaceAll('₩', '').replaceAll(',', '').trim(); final int parsedValue = int.tryParse(numericValue) ?? 0; - monthlyCostController.text = + monthlyCostController.text = NumberFormat.decimalPattern().format(parsedValue); } } else { monthlyCostController.text = ''; } - + billingCycle = subscription['billingCycle'] ?? '월간'; nextBillingDate = subscription['nextBillingDate'] != null ? DateTime.parse(subscription['nextBillingDate']) : DateTime.now(); - + // 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도 - if (serviceInfo == null && - subscription['serviceName'] != null && + if (serviceInfo == null && + subscription['serviceName'] != null && subscription['serviceName'].isNotEmpty) { - final suggestedUrl = + final suggestedUrl = SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); if (suggestedUrl != null && websiteUrlController.text.isEmpty) { websiteUrlController.text = suggestedUrl; } - + // 서비스명 기반으로 카테고리 자동 선택 autoSelectCategory(); } - + // 애니메이션 재생 animationController!.reset(); animationController!.forward(); @@ -396,7 +404,8 @@ class AddSubscriptionController { if (context.mounted) { AppSnackBar.showError( context: context, - message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()), + message: AppLocalizations.of(context) + .smsScanErrorWithMessage(e.toString()), ); } } finally { @@ -412,20 +421,19 @@ class AddSubscriptionController { setState(() { isLoading = true; }); - + try { // 콤마 제거하고 숫자만 추출 - final monthlyCost = + final monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', '')); - + // 이벤트 가격 파싱 double? eventPrice; if (isEventActive && eventPriceController.text.isNotEmpty) { - eventPrice = double.tryParse( - eventPriceController.text.replaceAll(',', '') - ); + eventPrice = + double.tryParse(eventPriceController.text.replaceAll(',', '')); } - + await Provider.of(context, listen: false) .addSubscription( serviceName: serviceNameController.text.trim(), @@ -440,7 +448,7 @@ class AddSubscriptionController { eventEndDate: eventEndDate, eventPrice: eventPrice, ); - + if (context.mounted) { Navigator.pop(context, true); // 성공 여부 반환 } @@ -448,11 +456,12 @@ class AddSubscriptionController { setState(() { isLoading = false; }); - + if (context.mounted) { AppSnackBar.showError( context: context, - message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()), + message: + AppLocalizations.of(context).saveErrorWithMessage(e.toString()), ); } } @@ -464,4 +473,4 @@ class AddSubscriptionController { ); } } -} \ No newline at end of file +} diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index 0036f79..cd2e67b 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -17,17 +17,17 @@ import '../l10n/app_localizations.dart'; class DetailScreenController extends ChangeNotifier { final BuildContext context; final SubscriptionModel subscription; - + // Text Controllers late TextEditingController serviceNameController; late TextEditingController monthlyCostController; late TextEditingController websiteUrlController; late TextEditingController eventPriceController; - + // Display Names String? _displayName; String? get displayName => _displayName; - + // Form State final GlobalKey formKey = GlobalKey(); late String _billingCycle; @@ -35,12 +35,12 @@ class DetailScreenController extends ChangeNotifier { String? _selectedCategoryId; late String _currency; bool _isLoading = false; - + // Event State late bool _isEventActive; DateTime? _eventStartDate; DateTime? _eventEndDate; - + // Getters String get billingCycle => _billingCycle; DateTime get nextBillingDate => _nextBillingDate; @@ -50,7 +50,7 @@ class DetailScreenController extends ChangeNotifier { bool get isEventActive => _isEventActive; DateTime? get eventStartDate => _eventStartDate; DateTime? get eventEndDate => _eventEndDate; - + // Setters set billingCycle(String value) { if (_billingCycle != value) { @@ -58,21 +58,21 @@ class DetailScreenController extends ChangeNotifier { notifyListeners(); } } - + set nextBillingDate(DateTime value) { if (_nextBillingDate != value) { _nextBillingDate = value; notifyListeners(); } } - + set selectedCategoryId(String? value) { if (_selectedCategoryId != value) { _selectedCategoryId = value; notifyListeners(); } } - + set currency(String value) { if (_currency != value) { _currency = value; @@ -80,35 +80,35 @@ class DetailScreenController extends ChangeNotifier { notifyListeners(); } } - + set isLoading(bool value) { if (_isLoading != value) { _isLoading = value; notifyListeners(); } } - + set isEventActive(bool value) { if (_isEventActive != value) { _isEventActive = value; notifyListeners(); } } - + set eventStartDate(DateTime? value) { if (_eventStartDate != value) { _eventStartDate = value; notifyListeners(); } } - + set eventEndDate(DateTime? value) { if (_eventEndDate != value) { _eventEndDate = value; notifyListeners(); } } - + // Focus Nodes final serviceNameFocus = FocusNode(); final monthlyCostFocus = FocusNode(); @@ -117,7 +117,7 @@ class DetailScreenController extends ChangeNotifier { final websiteUrlFocus = FocusNode(); final categoryFocus = FocusNode(); final currencyFocus = FocusNode(); - + // UI State final ScrollController scrollController = ScrollController(); double scrollOffset = 0; @@ -125,7 +125,7 @@ class DetailScreenController extends ChangeNotifier { bool isDeleteHovered = false; bool isSaveHovered = false; bool isCancelHovered = false; - + // Animation Controller AnimationController? animationController; Animation? fadeAnimation; @@ -140,42 +140,45 @@ class DetailScreenController extends ChangeNotifier { /// 초기화 void initialize({required TickerProvider vsync}) { // Text Controllers 초기화 - serviceNameController = TextEditingController(text: subscription.serviceName); - monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString()); - websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? ''); + serviceNameController = + TextEditingController(text: subscription.serviceName); + monthlyCostController = + TextEditingController(text: subscription.monthlyCost.toString()); + websiteUrlController = + TextEditingController(text: subscription.websiteUrl ?? ''); eventPriceController = TextEditingController(); - + // Form State 초기화 _billingCycle = subscription.billingCycle; _nextBillingDate = subscription.nextBillingDate; _selectedCategoryId = subscription.categoryId; _currency = subscription.currency; - + // Event State 초기화 _isEventActive = subscription.isEventActive; _eventStartDate = subscription.eventStartDate; _eventEndDate = subscription.eventEndDate; - + // 이벤트 가격 초기화 if (subscription.eventPrice != null) { if (currency == 'KRW') { eventPriceController.text = NumberFormat.decimalPattern() .format(subscription.eventPrice!.toInt()); } else { - eventPriceController.text = + eventPriceController.text = NumberFormat('#,##0.00').format(subscription.eventPrice!); } } - + // 통화 단위에 따른 금액 표시 형식 조정 _updateMonthlyCostFormat(); - + // 애니메이션 초기화 animationController = AnimationController( duration: const Duration(milliseconds: 800), vsync: vsync, ); - + fadeAnimation = Tween( begin: 0.0, end: 1.0, @@ -183,7 +186,7 @@ class DetailScreenController extends ChangeNotifier { parent: animationController!, curve: Curves.easeInOut, )); - + slideAnimation = Tween( begin: const Offset(0.0, 0.3), end: Offset.zero, @@ -191,7 +194,7 @@ class DetailScreenController extends ChangeNotifier { parent: animationController!, curve: Curves.easeOutCubic, )); - + rotateAnimation = Tween( begin: 0.0, end: 1.0, @@ -199,16 +202,16 @@ class DetailScreenController extends ChangeNotifier { parent: animationController!, curve: Curves.easeOutCubic, )); - + // 애니메이션 시작 animationController!.forward(); - + // 로케일에 맞는 서비스명 로드 _loadDisplayName(); - + // 서비스명 변경 감지 리스너 serviceNameController.addListener(onServiceNameChanged); - + // 스크롤 리스너 scrollController.addListener(() { scrollOffset = scrollController.offset; @@ -219,16 +222,16 @@ class DetailScreenController extends ChangeNotifier { Future _loadDisplayName() async { final localeProvider = context.read(); final locale = localeProvider.locale.languageCode; - + final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: subscription.serviceName, locale: locale, ); - + _displayName = displayName; notifyListeners(); } - + /// 리소스 정리 @override void dispose() { @@ -237,7 +240,7 @@ class DetailScreenController extends ChangeNotifier { monthlyCostController.dispose(); websiteUrlController.dispose(); eventPriceController.dispose(); - + // Focus Nodes serviceNameFocus.dispose(); monthlyCostFocus.dispose(); @@ -246,13 +249,13 @@ class DetailScreenController extends ChangeNotifier { websiteUrlFocus.dispose(); categoryFocus.dispose(); currencyFocus.dispose(); - + // Animation animationController?.dispose(); - + // Scroll scrollController.dispose(); - + super.dispose(); } @@ -261,10 +264,12 @@ class DetailScreenController extends ChangeNotifier { if (_currency == 'KRW') { // 원화는 소수점 없이 표시 final intValue = subscription.monthlyCost.toInt(); - monthlyCostController.text = NumberFormat.decimalPattern().format(intValue); + monthlyCostController.text = + NumberFormat.decimalPattern().format(intValue); } else { // 달러는 소수점 2자리까지 표시 - monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost); + monthlyCostController.text = + NumberFormat('#,##0.00').format(subscription.monthlyCost); } } @@ -275,17 +280,18 @@ class DetailScreenController extends ChangeNotifier { /// 카테고리 자동 선택 void autoSelectCategory() { - final categoryProvider = Provider.of(context, listen: false); + final categoryProvider = + Provider.of(context, listen: false); final categories = categoryProvider.categories; - + final serviceName = serviceNameController.text.toLowerCase(); - + // 서비스명에 기반한 카테고리 매칭 로직 CategoryModel? matchedCategory; - + // 엔터테인먼트 관련 키워드 - if (serviceName.contains('netflix') || - serviceName.contains('youtube') || + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || serviceName.contains('disney') || serviceName.contains('왓챠') || serviceName.contains('티빙') || @@ -298,64 +304,64 @@ class DetailScreenController extends ChangeNotifier { ); } // 음악 관련 키워드 - else if (serviceName.contains('spotify') || - serviceName.contains('apple music') || - serviceName.contains('멜론') || - serviceName.contains('지니') || - serviceName.contains('플로') || - serviceName.contains('벅스')) { + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('멜론') || + serviceName.contains('지니') || + serviceName.contains('플로') || + serviceName.contains('벅스')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'music', orElse: () => categories.first, ); } // 생산성 관련 키워드 - else if (serviceName.contains('notion') || - serviceName.contains('microsoft') || - serviceName.contains('office') || - serviceName.contains('google') || - serviceName.contains('dropbox') || - serviceName.contains('icloud') || - serviceName.contains('adobe')) { + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('google') || + serviceName.contains('dropbox') || + serviceName.contains('icloud') || + serviceName.contains('adobe')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'collaborationOffice', orElse: () => categories.first, ); } // AI 관련 키워드 - else if (serviceName.contains('chatgpt') || - serviceName.contains('claude') || - serviceName.contains('gemini') || - serviceName.contains('copilot') || - serviceName.contains('midjourney')) { + else if (serviceName.contains('chatgpt') || + serviceName.contains('claude') || + serviceName.contains('gemini') || + serviceName.contains('copilot') || + serviceName.contains('midjourney')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'aiService', orElse: () => categories.first, ); } // 교육 관련 키워드 - else if (serviceName.contains('coursera') || - serviceName.contains('udemy') || - serviceName.contains('인프런') || - serviceName.contains('패스트캠퍼스') || - serviceName.contains('클래스101')) { + else if (serviceName.contains('coursera') || + serviceName.contains('udemy') || + serviceName.contains('인프런') || + serviceName.contains('패스트캠퍼스') || + serviceName.contains('클래스101')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'programming', orElse: () => categories.first, ); } // 쇼핑 관련 키워드 - else if (serviceName.contains('쿠팡') || - serviceName.contains('coupang') || - serviceName.contains('amazon') || - serviceName.contains('네이버') || - serviceName.contains('11번가')) { + else if (serviceName.contains('쿠팡') || + serviceName.contains('coupang') || + serviceName.contains('amazon') || + serviceName.contains('네이버') || + serviceName.contains('11번가')) { matchedCategory = categories.firstWhere( (cat) => cat.name == 'other', orElse: () => categories.first, ); } - + if (matchedCategory != null) { selectedCategoryId = matchedCategory.id; } @@ -371,30 +377,32 @@ class DetailScreenController extends ChangeNotifier { ); return; } - + final provider = Provider.of(context, listen: false); - + // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 String? websiteUrl = websiteUrlController.text; if (websiteUrl.isEmpty) { - websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text); + websiteUrl = + SubscriptionUrlMatcher.suggestUrl(serviceNameController.text); } - + // 구독 정보 업데이트 - + // 콤마 제거하고 숫자만 추출 double monthlyCost = 0.0; try { - monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', '')); + monthlyCost = + double.parse(monthlyCostController.text.replaceAll(',', '')); } catch (e) { // 파싱 오류 발생 시 기본값 사용 monthlyCost = subscription.monthlyCost; } - + debugPrint('[DetailScreenController] 구독 업데이트 시작: ' '${subscription.serviceName} → ${serviceNameController.text}, ' - '금액: ${subscription.monthlyCost} → $monthlyCost ${_currency}'); - + '금액: $subscription.monthlyCost → $monthlyCost $_currency'); + subscription.serviceName = serviceNameController.text; subscription.monthlyCost = monthlyCost; subscription.websiteUrl = websiteUrl; @@ -402,16 +410,16 @@ class DetailScreenController extends ChangeNotifier { subscription.nextBillingDate = _nextBillingDate; subscription.categoryId = _selectedCategoryId; subscription.currency = _currency; - + // 이벤트 정보 업데이트 subscription.isEventActive = _isEventActive; subscription.eventStartDate = _eventStartDate; subscription.eventEndDate = _eventEndDate; - + // 이벤트 가격 파싱 if (_isEventActive && eventPriceController.text.isNotEmpty) { try { - subscription.eventPrice = + subscription.eventPrice = double.parse(eventPriceController.text.replaceAll(',', '')); } catch (e) { subscription.eventPrice = null; @@ -419,20 +427,20 @@ class DetailScreenController extends ChangeNotifier { } else { subscription.eventPrice = null; } - + debugPrint('[DetailScreenController] 업데이트 정보: ' '현재가격=${subscription.currentPrice}, ' '이벤트활성=${subscription.isEventActive}'); - + // 구독 업데이트 await provider.updateSubscription(subscription); - + if (context.mounted) { AppSnackBar.showSuccess( context: context, message: AppLocalizations.of(context).subscriptionUpdated, ); - + // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 await Future.delayed(const Duration(milliseconds: 100)); if (context.mounted) { @@ -445,30 +453,35 @@ class DetailScreenController extends ChangeNotifier { Future deleteSubscription() async { if (context.mounted) { // 로케일에 맞는 서비스명 가져오기 - final localeProvider = Provider.of(context, listen: false); + final localeProvider = + Provider.of(context, listen: false); final locale = localeProvider.locale.languageCode; final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: subscription.serviceName, locale: locale, ); - + if (!context.mounted) return; + // 삭제 확인 다이얼로그 표시 final shouldDelete = await DeleteConfirmationDialog.show( context: context, serviceName: displayName, ); - + if (!context.mounted) return; + if (!shouldDelete) return; - + // 사용자가 확인한 경우에만 삭제 진행 if (context.mounted) { - final provider = Provider.of(context, listen: false); + final provider = + Provider.of(context, listen: false); await provider.deleteSubscription(subscription.id); - + if (context.mounted) { AppSnackBar.showError( context: context, - message: AppLocalizations.of(context).subscriptionDeleted(displayName), + message: + AppLocalizations.of(context).subscriptionDeleted(displayName), icon: Icons.delete_forever_rounded, ); Navigator.of(context).pop(); @@ -482,19 +495,22 @@ class DetailScreenController extends ChangeNotifier { try { // 1. 현재 언어 설정 가져오기 final locale = Localizations.localeOf(context).languageCode; - + // 2. 해지 안내 URL 찾기 - String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl( + String? cancellationUrl = + await SubscriptionUrlMatcher.findCancellationUrl( serviceName: subscription.serviceName, websiteUrl: subscription.websiteUrl, locale: locale == 'ko' ? 'kr' : 'en', ); - + // 3. 해지 안내 URL이 없으면 구글 검색 if (cancellationUrl == null) { - final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}'; - cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}'; - + final searchQuery = + '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}'; + cancellationUrl = + 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}'; + if (context.mounted) { AppSnackBar.showInfo( context: context, @@ -502,7 +518,7 @@ class DetailScreenController extends ChangeNotifier { ); } } - + // 4. URL 열기 final Uri url = Uri.parse(cancellationUrl); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { @@ -515,11 +531,12 @@ class DetailScreenController extends ChangeNotifier { } } catch (e) { if (kDebugMode) { + // ignore: avoid_print print('DetailScreenController: 해지 페이지 열기 실패 - $e'); } - + // 오류 발생시 일반 웹사이트로 폴백 - if (subscription.websiteUrl != null && + if (subscription.websiteUrl != null && subscription.websiteUrl!.isNotEmpty) { final Uri url = Uri.parse(subscription.websiteUrl!); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { @@ -554,7 +571,7 @@ class DetailScreenController extends ChangeNotifier { const Color(0xFF0EA5E9), // 하늘 const Color(0xFFEC4899), // 분홍 ]; - + return colors[hash % colors.length]; } @@ -569,4 +586,4 @@ class DetailScreenController extends ChangeNotifier { end: Alignment.bottomRight, ); } -} \ No newline at end of file +} diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart index 7551cab..7e9da50 100644 --- a/lib/controllers/sms_scan_controller.dart +++ b/lib/controllers/sms_scan_controller.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import '../services/sms_scanner.dart'; import '../models/subscription.dart'; -import '../models/subscription_model.dart'; import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_filter.dart'; import '../providers/subscription_provider.dart'; import 'package:provider/provider.dart'; +import '../utils/logger.dart'; import '../providers/navigation_provider.dart'; import '../providers/category_provider.dart'; import '../l10n/app_localizations.dart'; @@ -58,18 +58,20 @@ class SmsScanController extends ChangeNotifier { try { // SMS 스캔 실행 - print('SMS 스캔 시작'); - final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions(); - print('스캔된 구독: ${scannedSubscriptionModels.length}개'); + Log.i('SMS 스캔 시작'); + final scannedSubscriptionModels = + await _smsScanner.scanForSubscriptions(); + Log.d('스캔된 구독: ${scannedSubscriptionModels.length}개'); if (scannedSubscriptionModels.isNotEmpty) { - print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); + Log.d( + '첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); } if (!context.mounted) return; if (scannedSubscriptionModels.isEmpty) { - print('스캔된 구독이 없음'); + Log.i('스캔된 구독이 없음'); _errorMessage = AppLocalizations.of(context).subscriptionNotFound; _isLoading = false; notifyListeners(); @@ -77,18 +79,21 @@ class SmsScanController extends ChangeNotifier { } // SubscriptionModel을 Subscription으로 변환 - final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels); + final scannedSubscriptions = + _converter.convertModelsToSubscriptions(scannedSubscriptionModels); // 2회 이상 반복 결제된 구독만 필터링 - final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2); - print('반복 결제된 구독: ${repeatSubscriptions.length}개'); + final repeatSubscriptions = + _filter.filterByRepeatCount(scannedSubscriptions, 2); + Log.d('반복 결제된 구독: ${repeatSubscriptions.length}개'); if (repeatSubscriptions.isNotEmpty) { - print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); + Log.d( + '첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); } if (repeatSubscriptions.isEmpty) { - print('반복 결제된 구독이 없음'); + Log.i('반복 결제된 구독이 없음'); _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; _isLoading = false; notifyListeners(); @@ -96,21 +101,24 @@ class SmsScanController extends ChangeNotifier { } // 구독 목록 가져오기 - final provider = Provider.of(context, listen: false); + final provider = + Provider.of(context, listen: false); final existingSubscriptions = provider.subscriptions; - print('기존 구독: ${existingSubscriptions.length}개'); + Log.d('기존 구독: ${existingSubscriptions.length}개'); // 중복 구독 필터링 - final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); - print('중복 제거 후 구독: ${filteredSubscriptions.length}개'); + final filteredSubscriptions = + _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); + Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}개'); if (filteredSubscriptions.isNotEmpty) { - print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); + Log.d( + '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); } // 중복 제거 후 신규 구독이 없는 경우 if (filteredSubscriptions.isEmpty) { - print('중복 제거 후 신규 구독이 없음'); + Log.i('중복 제거 후 신규 구독이 없음'); _isLoading = false; notifyListeners(); return; @@ -121,9 +129,10 @@ class SmsScanController extends ChangeNotifier { websiteUrlController.text = ''; // URL 입력 필드 초기화 notifyListeners(); } catch (e) { - print('SMS 스캔 중 오류 발생: $e'); + Log.e('SMS 스캔 중 오류 발생', e); if (context.mounted) { - _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); + _errorMessage = + AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); _isLoading = false; notifyListeners(); } @@ -134,20 +143,25 @@ class SmsScanController extends ChangeNotifier { if (_currentIndex >= _scannedSubscriptions.length) return; final subscription = _scannedSubscriptions[_currentIndex]; - + try { - final provider = Provider.of(context, listen: false); - final categoryProvider = Provider.of(context, listen: false); - - final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider); - + final provider = + Provider.of(context, listen: false); + final categoryProvider = + Provider.of(context, listen: false); + + final finalCategoryId = _selectedCategoryId ?? + subscription.category ?? + getDefaultCategoryId(categoryProvider); + // websiteUrl 처리 - final websiteUrl = websiteUrlController.text.trim().isNotEmpty - ? websiteUrlController.text.trim() + final websiteUrl = websiteUrlController.text.trim().isNotEmpty + ? websiteUrlController.text.trim() : subscription.websiteUrl; - print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); - + Log.d( + '구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); + // addSubscription 호출 await provider.addSubscription( serviceName: subscription.serviceName, @@ -161,20 +175,21 @@ class SmsScanController extends ChangeNotifier { categoryId: finalCategoryId, currency: subscription.currency, ); - - print('구독 추가 성공: ${subscription.serviceName}'); - + + Log.i('구독 추가 성공: ${subscription.serviceName}'); + if (!context.mounted) return; moveToNextSubscription(context); } catch (e) { - print('구독 추가 중 오류 발생: $e'); + Log.e('구독 추가 중 오류 발생', e); // 오류가 있어도 다음 구독으로 이동 + if (!context.mounted) return; moveToNextSubscription(context); } } void skipCurrentSubscription(BuildContext context) { final subscription = _scannedSubscriptions[_currentIndex]; - print('구독 건너뛰기: ${subscription.serviceName}'); + Log.i('구독 건너뛰기: ${subscription.serviceName}'); moveToNextSubscription(context); } @@ -187,13 +202,14 @@ class SmsScanController extends ChangeNotifier { if (_currentIndex >= _scannedSubscriptions.length) { navigateToHome(context); } - + notifyListeners(); } void navigateToHome(BuildContext context) { // NavigationProvider를 사용하여 홈 화면으로 이동 - final navigationProvider = Provider.of(context, listen: false); + final navigationProvider = + Provider.of(context, listen: false); navigationProvider.updateCurrentIndex(0); } @@ -209,7 +225,7 @@ class SmsScanController extends ChangeNotifier { (cat) => cat.name == 'other', orElse: () => categoryProvider.categories.first, ); - print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); + Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); return otherCategory.id; } @@ -221,4 +237,4 @@ class SmsScanController extends ChangeNotifier { } } } -} \ No newline at end of file +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d22b157..8ea4702 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -14,22 +14,21 @@ class AppLocalizations { // JSON 파일에서 번역 데이터 로드 Future load() async { - String jsonString = - await rootBundle.loadString('assets/data/text.json'); + String jsonString = await rootBundle.loadString('assets/data/text.json'); Map jsonMap = json.decode(jsonString); _localizedStrings = jsonMap[locale.languageCode]; } String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager'; - String get appSubtitle => _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily'; + String get appSubtitle => + _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily'; String get subscriptionManagement => _localizedStrings['subscriptionManagement'] ?? 'Subscription Management'; String get addSubscription => _localizedStrings['addSubscription'] ?? 'Add Subscription'; String get subscriptionName => _localizedStrings['subscriptionName'] ?? 'Service Name'; - String get monthlyCost => - _localizedStrings['monthlyCost'] ?? 'Monthly Cost'; + String get monthlyCost => _localizedStrings['monthlyCost'] ?? 'Monthly Cost'; String get billingCycle => _localizedStrings['billingCycle'] ?? 'Billing Cycle'; String get nextBillingDate => @@ -55,29 +54,50 @@ class AppLocalizations { _localizedStrings['categoryManagement'] ?? 'Category Management'; String get categoryName => _localizedStrings['categoryName'] ?? 'Category Name'; - String get selectColor => - _localizedStrings['selectColor'] ?? 'Select Color'; - String get selectIcon => - _localizedStrings['selectIcon'] ?? 'Select Icon'; - String get addCategory => - _localizedStrings['addCategory'] ?? 'Add Category'; + String get selectColor => _localizedStrings['selectColor'] ?? 'Select Color'; + String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon'; + String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category'; String get settings => _localizedStrings['settings'] ?? 'Settings'; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; String get language => _localizedStrings['language'] ?? 'Language'; String get notifications => _localizedStrings['notifications'] ?? 'Notifications'; String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; + // SMS 권한 온보딩/설정 + String get smsPermissionTitle => + _localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission'; + String get smsPermissionReasonTitle => + _localizedStrings['smsPermissionReasonTitle'] ?? 'Why'; + String get smsPermissionReasonBody => + _localizedStrings['smsPermissionReasonBody'] ?? + 'We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.'; + String get smsPermissionScopeTitle => + _localizedStrings['smsPermissionScopeTitle'] ?? 'Scope'; + String get smsPermissionScopeBody => + _localizedStrings['smsPermissionScopeBody'] ?? + 'We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.'; + String get permanentlyDeniedMessage => + _localizedStrings['permanentlyDeniedMessage'] ?? + 'Permission is permanently denied. Enable it in Settings.'; + String get openSettings => + _localizedStrings['openSettings'] ?? 'Open Settings'; + String get later => _localizedStrings['later'] ?? 'Later'; + String get requesting => _localizedStrings['requesting'] ?? 'Requesting...'; + String get smsPermissionLabel => + _localizedStrings['smsPermissionLabel'] ?? 'SMS Permission'; // 알림 설정 String get notificationPermission => _localizedStrings['notificationPermission'] ?? 'Notification Permission'; String get notificationPermissionDesc => - _localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications'; + _localizedStrings['notificationPermissionDesc'] ?? + 'Permission is required to receive notifications'; String get requestPermission => _localizedStrings['requestPermission'] ?? 'Request Permission'; String get paymentNotification => _localizedStrings['paymentNotification'] ?? 'Payment Due Notification'; String get paymentNotificationDesc => - _localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date'; + _localizedStrings['paymentNotificationDesc'] ?? + 'Receive notification on payment due date'; String get notificationTiming => _localizedStrings['notificationTiming'] ?? 'Notification Timing'; String get notificationTime => @@ -85,11 +105,14 @@ class AppLocalizations { String get dailyReminder => _localizedStrings['dailyReminder'] ?? 'Daily Reminder'; String get dailyReminderEnabled => - _localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date'; + _localizedStrings['dailyReminderEnabled'] ?? + 'Receive daily notifications until payment date'; String get dailyReminderDisabled => - _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment'; + _localizedStrings['dailyReminderDisabled'] ?? + 'Receive notification @ day(s) before payment'; String get notificationPermissionDenied => - _localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied'; + _localizedStrings['notificationPermissionDenied'] ?? + 'Notification permission denied'; // 앱 정보 String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get version => _localizedStrings['version'] ?? 'Version'; @@ -102,7 +125,8 @@ class AppLocalizations { String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light'; String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark'; String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black'; - String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default'; + String get systemTheme => + _localizedStrings['systemTheme'] ?? 'System Default'; // 기타 메시지 String get subscriptionAdded => _localizedStrings['subscriptionAdded'] ?? 'Subscription added'; @@ -112,190 +136,302 @@ class AppLocalizations { String get japanese => _localizedStrings['japanese'] ?? '日本語'; String get chinese => _localizedStrings['chinese'] ?? '中文'; // 날짜 - String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before'; - String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before'; - String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days before'; + String get oneDayBefore => + _localizedStrings['oneDayBefore'] ?? '1 day before'; + String get twoDaysBefore => + _localizedStrings['twoDaysBefore'] ?? '2 days before'; + String get threeDaysBefore => + _localizedStrings['threeDaysBefore'] ?? '3 days before'; // 추가 메시지 - String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields'; - String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated'; - String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.'; - String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website'; - String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.'; + String get requiredFieldsError => + _localizedStrings['requiredFieldsError'] ?? + 'Please fill in all required fields'; + String get subscriptionUpdated => + _localizedStrings['subscriptionUpdated'] ?? + 'Subscription information has been updated'; + String get officialCancelPageNotFound => + _localizedStrings['officialCancelPageNotFound'] ?? + 'Official cancellation page not found. Redirecting to Google search.'; + String get cannotOpenWebsite => + _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website'; + String get noWebsiteInfo => + _localizedStrings['noWebsiteInfo'] ?? + 'No website information available. Please cancel through the website.'; String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode'; - String get changesAppliedAfterSave => _localizedStrings['changesAppliedAfterSave'] ?? 'Changes will be applied after saving'; + String get changesAppliedAfterSave => + _localizedStrings['changesAppliedAfterSave'] ?? + 'Changes will be applied after saving'; String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes'; - String get monthlyExpense => _localizedStrings['monthlyExpense'] ?? 'Monthly Expense'; + String get monthlyExpense => + _localizedStrings['monthlyExpense'] ?? 'Monthly Expense'; String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL'; - String get websiteUrlOptional => _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)'; + String get websiteUrlOptional => + _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)'; String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price'; - String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price'; - String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price'; - String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid price'; + String get eventPriceHint => + _localizedStrings['eventPriceHint'] ?? 'Enter discounted price'; + String get eventPriceRequired => + _localizedStrings['eventPriceRequired'] ?? 'Please enter event price'; + String get invalidPrice => + _localizedStrings['invalidPrice'] ?? 'Please enter a valid price'; String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS'; String get home => _localizedStrings['home'] ?? 'Home'; String get analysis => _localizedStrings['analysis'] ?? 'Analysis'; String get back => _localizedStrings['back'] ?? 'Back'; String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App'; - String get exitAppConfirm => _localizedStrings['exitAppConfirm'] ?? 'Are you sure you want to exit SubManager?'; + String get exitAppConfirm => + _localizedStrings['exitAppConfirm'] ?? + 'Are you sure you want to exit SubManager?'; String get exit => _localizedStrings['exit'] ?? 'Exit'; - String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found'; - String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify'; - String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com'; - String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication'; - String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication'; - String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.'; - String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; - String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found'; - String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan'; - String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving'; - String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found'; - String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription'; - String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.'; - String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)'; + String get pageNotFound => + _localizedStrings['pageNotFound'] ?? 'Page not found'; + String get serviceNameExample => + _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify'; + String get urlExample => + _localizedStrings['urlExample'] ?? 'https://example.com'; + String get appLockDesc => + _localizedStrings['appLockDesc'] ?? + 'App lock with biometric authentication'; + String get unlockWithBiometric => + _localizedStrings['unlockWithBiometric'] ?? + 'Unlock with biometric authentication'; + String get authenticationFailed => + _localizedStrings['authenticationFailed'] ?? + 'Authentication failed. Please try again.'; + String get smsPermissionRequired => + _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; + String get noSubscriptionSmsFound => + _localizedStrings['noSubscriptionSmsFound'] ?? + 'No subscription related SMS found'; + String get smsScanError => + _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan'; + String get saveError => + _localizedStrings['saveError'] ?? 'Error occurred while saving'; + String get newSubscriptionSmsNotFound => + _localizedStrings['newSubscriptionSmsNotFound'] ?? + 'No new subscription SMS found'; + String get subscriptionAddError => + _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription'; + String get allSubscriptionsProcessed => + _localizedStrings['allSubscriptionsProcessed'] ?? + 'All subscriptions have been processed.'; + String get websiteUrlExtracted => + _localizedStrings['websiteUrlExtracted'] ?? + 'Website URL (Auto-extracted)'; String get startDate => _localizedStrings['startDate'] ?? 'Start Date'; String get endDate => _localizedStrings['endDate'] ?? 'End Date'; - + // 새로 추가된 항목들 - String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost'; - String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate'; + String get monthlyTotalSubscriptionCost => + _localizedStrings['monthlyTotalSubscriptionCost'] ?? + 'Total Monthly Subscription Cost'; + String get todaysExchangeRate => + _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate'; String get won => _localizedStrings['won'] ?? 'KRW'; - String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost'; - String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services'; + String get estimatedAnnualCost => + _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost'; + String get totalSubscriptionServices => + _localizedStrings['totalSubscriptionServices'] ?? + 'Total Subscription Services'; String get services => _localizedStrings['services'] ?? 'services'; - String get eventDiscountActive => _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active'; + String get eventDiscountActive => + _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active'; String get saving => _localizedStrings['saving'] ?? 'Saving'; - String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today'; - String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed'; + String get paymentDueToday => + _localizedStrings['paymentDueToday'] ?? 'Payment Due Today'; + String get paymentInfoNeeded => + _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed'; String get event => _localizedStrings['event'] ?? 'Event'; - + // 카테고리 getter들 String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music'; - String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)'; - String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud'; - String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV'; - String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle'; - String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce'; - String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming'; - String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office'; - String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service'; + String get categoryOttVideo => + _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)'; + String get categoryStorageCloud => + _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud'; + String get categoryTelecomInternetTv => + _localizedStrings['categoryTelecomInternetTv'] ?? + 'Telecom · Internet · TV'; + String get categoryLifestyle => + _localizedStrings['categoryLifestyle'] ?? 'Lifestyle'; + String get categoryShoppingEcommerce => + _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce'; + String get categoryProgramming => + _localizedStrings['categoryProgramming'] ?? 'Programming'; + String get categoryCollaborationOffice => + _localizedStrings['categoryCollaborationOffice'] ?? + 'Collaboration/Office'; + String get categoryAiService => + _localizedStrings['categoryAiService'] ?? 'AI Service'; String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other'; - + // 동적 메시지 생성 메서드 String daysBefore(int days) { return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}'; } - + String dailyReminderDisabledWithDays(int days) { - final template = _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment'; + final template = _localizedStrings['dailyReminderDisabled'] ?? + 'Receive notification @ day(s) before payment'; return template.replaceAll('@', days.toString()); } - + String subscriptionAddedWithName(String serviceName) { - final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.'; + final template = + _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.'; return template.replaceAll('@', serviceName); } - + String subscriptionDeleted(String serviceName) { - final template = _localizedStrings['subscriptionDeleted'] ?? '@ subscription has been deleted'; + final template = _localizedStrings['subscriptionDeleted'] ?? + '@ subscription has been deleted'; return template.replaceAll('@', serviceName); } - + String totalExpenseCopied(String amount) { - final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @'; + final template = + _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @'; return template.replaceAll('@', amount); } - + String serviceRecognized(String serviceName) { - final template = _localizedStrings['serviceRecognized'] ?? '@ service has been recognized automatically.'; + final template = _localizedStrings['serviceRecognized'] ?? + '@ service has been recognized automatically.'; return template.replaceAll('@', serviceName); } - + String smsScanErrorWithMessage(String error) { - final template = _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan: @'; + final template = _localizedStrings['smsScanError'] ?? + 'Error occurred during SMS scan: @'; return template.replaceAll('@', error); } - + String saveErrorWithMessage(String error) { - final template = _localizedStrings['saveError'] ?? 'Error occurred while saving: @'; + final template = + _localizedStrings['saveError'] ?? 'Error occurred while saving: @'; return template.replaceAll('@', error); } - + String subscriptionAddErrorWithMessage(String error) { - final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @'; + final template = _localizedStrings['subscriptionAddError'] ?? + 'Error adding subscription: @'; return template.replaceAll('@', error); } - + String subscriptionSkipped(String serviceName) { - final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.'; + final template = + _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.'; return template.replaceAll('@', serviceName); } - + // 홈화면 관련 - String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions'; - + String get mySubscriptions => + _localizedStrings['mySubscriptions'] ?? 'My Subscriptions'; + String subscriptionCount(int count) { if (locale.languageCode == 'ko') { - return '${count}개'; + return '$count개'; } else if (locale.languageCode == 'ja') { - return '${count}個'; + return '$count個'; } else if (locale.languageCode == 'zh') { - return '${count}个'; + return '$count个'; } else { return count.toString(); } } - + // 분석화면 관련 - String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status'; - String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend'; - String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense'; - String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio'; - String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense'; - String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services'; - String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary'; - String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount'; - String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense'; - String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services'; + String get monthlyExpenseTitle => + _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status'; + String get recentSixMonthsTrend => + _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend'; + String get monthlySubscriptionExpense => + _localizedStrings['monthlySubscriptionExpense'] ?? + 'Monthly subscription expense'; + String get subscriptionServiceRatio => + _localizedStrings['subscriptionServiceRatio'] ?? + 'Subscription Service Ratio'; + String get monthlyExpenseBasis => + _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense'; + String get noSubscriptionServices => + _localizedStrings['noSubscriptionServices'] ?? 'No subscription services'; + String get totalExpenseSummary => + _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary'; + String get monthlyTotalAmount => + _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount'; + String get totalExpense => + _localizedStrings['totalExpense'] ?? 'Total Expense'; + String get totalServices => + _localizedStrings['totalServices'] ?? 'Total Services'; String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services'; String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost'; - String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; - String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress'; - String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount'; - String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress'; - String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount'; + String get eventDiscountStatus => + _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; + String get inProgressUnit => + _localizedStrings['inProgressUnit'] ?? 'in progress'; + String get monthlySavingAmount => + _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount'; + String get eventsInProgress => + _localizedStrings['eventsInProgress'] ?? 'Events in Progress'; + String get discountPercent => + _localizedStrings['discountPercent'] ?? '% discount'; String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW'; - + // SMS 스캔 관련 - String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...'; - String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services'; - String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.'; - String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.'; - String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found'; - String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times'; - String get scanTextMessages => _localizedStrings['scanTextMessages'] ?? 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.'; - String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning'; - String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription'; + String get scanningMessages => + _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...'; + String get findingSubscriptions => + _localizedStrings['findingSubscriptions'] ?? + 'Finding subscription services'; + String get subscriptionNotFound => + _localizedStrings['subscriptionNotFound'] ?? + 'Subscription information not found.'; + String get repeatSubscriptionNotFound => + _localizedStrings['repeatSubscriptionNotFound'] ?? + 'No repeated subscription information found.'; + String get newSubscriptionNotFound => + _localizedStrings['newSubscriptionNotFound'] ?? + 'No new subscription SMS found'; + String get findRepeatSubscriptions => + _localizedStrings['findRepeatSubscriptions'] ?? + 'Find subscriptions paid 2+ times'; + String get scanTextMessages => + _localizedStrings['scanTextMessages'] ?? + 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.'; + String get startScanning => + _localizedStrings['startScanning'] ?? 'Start Scanning'; + String get foundSubscription => + _localizedStrings['foundSubscription'] ?? 'Found subscription'; String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name'; - String get nextBillingDateLabel => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; + String get nextBillingDateLabel => + _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date'; String get category => _localizedStrings['category'] ?? 'Category'; - String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)'; - String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty'; + String get websiteUrlAuto => + _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)'; + String get websiteUrlHint => + _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty'; String get skip => _localizedStrings['skip'] ?? 'Skip'; String get add => _localizedStrings['add'] ?? 'Add'; - String get nextBillingDateRequired => _localizedStrings['nextBillingDateRequired'] ?? 'Next billing date verification required'; - + String get nextBillingDateRequired => + _localizedStrings['nextBillingDateRequired'] ?? + 'Next billing date verification required'; + String nextBillingDateEstimated(String date, int days) { - final template = _localizedStrings['nextBillingDateEstimated'] ?? 'Next estimated billing date: @ (# days later)'; + final template = _localizedStrings['nextBillingDateEstimated'] ?? + 'Next estimated billing date: @ (# days later)'; return template.replaceAll('@', date).replaceAll('#', days.toString()); } - + String nextBillingDateInfo(String date, int days) { - final template = _localizedStrings['nextBillingDateInfo'] ?? 'Next billing date: @ (# days later)'; + final template = _localizedStrings['nextBillingDateInfo'] ?? + 'Next billing date: @ (# days later)'; return template.replaceAll('@', date).replaceAll('#', days.toString()); } - - String get nextBillingDatePastRequired => _localizedStrings['nextBillingDatePastRequired'] ?? 'Next billing date verification required (past date)'; - + + String get nextBillingDatePastRequired => + _localizedStrings['nextBillingDatePastRequired'] ?? + 'Next billing date verification required (past date)'; + String formatDate(DateTime date) { if (locale.languageCode == 'ko') { return '${date.year}년 ${date.month}월 ${date.day}일'; @@ -304,113 +440,165 @@ class AppLocalizations { } else if (locale.languageCode == 'zh') { return '${date.year}年${date.month}月${date.day}日'; } else { - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + final months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' + ]; return '${months[date.month - 1]} ${date.day}, ${date.year}'; } } - + String repeatCountDetected(int count) { - final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected'; + final template = + _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected'; return template.replaceAll('@', count.toString()); } - + String servicesInProgress(int count) { if (locale.languageCode == 'ko') { - return '${count}개 진행중'; + return '$count개 진행중'; } else if (locale.languageCode == 'ja') { - return '${count}個進行中'; + return '$count個進行中'; } else if (locale.languageCode == 'zh') { - return '${count}个进行中'; + return '$count个进行中'; } else { return '$count in progress'; } } - + // 새로 추가된 동적 메서드들 String paymentDueInDays(int days) { - final template = _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days'; + final template = + _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days'; return template.replaceAll('@', days.toString()); } - + String daysRemaining(int days) { final template = _localizedStrings['daysRemaining'] ?? '@ days remaining'; return template.replaceAll('@', days.toString()); } - + String exchangeRateFormat(String rate) { - final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @'; + final template = + _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @'; return template.replaceAll('@', rate); } - + // 결제 주기 결제 메시지 - String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment'; - + String get billingCyclePayment => + _localizedStrings['billingCyclePayment'] ?? '@ Payment'; + // 할인 금액 표시 getter들 - String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@'; - String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@'; - String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@'; - String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@'; - + String get discountAmountWon => + _localizedStrings['discountAmountWon'] ?? 'Save ₩@'; + String get discountAmountDollar => + _localizedStrings['discountAmountDollar'] ?? 'Save \$@'; + String get discountAmountYen => + _localizedStrings['discountAmountYen'] ?? 'Save ¥@'; + String get discountAmountYuan => + _localizedStrings['discountAmountYuan'] ?? 'Save ¥@'; + // 결제 주기 관련 getter String get monthly => _localizedStrings['monthly'] ?? 'Monthly'; String get weekly => _localizedStrings['weekly'] ?? 'Weekly'; String get yearly => _localizedStrings['yearly'] ?? 'Yearly'; - String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly'; - String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly'; - String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly'; - String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly'; - + String get billingCycleMonthly => + _localizedStrings['billingCycleMonthly'] ?? 'Monthly'; + String get billingCycleQuarterly => + _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly'; + String get billingCycleHalfYearly => + _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly'; + String get billingCycleYearly => + _localizedStrings['billingCycleYearly'] ?? 'Yearly'; + // 색상 관련 getter String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue'; String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green'; String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange'; String get colorRed => _localizedStrings['colorRed'] ?? 'Red'; String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple'; - + // 날짜 형식 관련 getter - String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy'; + String get dateFormatFull => + _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy'; String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd'; - + // USD 환율 표시 형식 - String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @'; - + String get exchangeRateDisplay => + _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @'; + // 라벨 및 힌트 텍스트 - String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name'; - String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify'; - String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense'; - String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date'; - String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)'; - String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com'; - String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price'; - String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price'; + String get labelServiceName => + _localizedStrings['labelServiceName'] ?? 'Service Name'; + String get hintServiceName => + _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify'; + String get labelMonthlyExpense => + _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense'; + String get labelNextBillingDate => + _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date'; + String get labelWebsiteUrl => + _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)'; + String get hintWebsiteUrl => + _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com'; + String get labelEventPrice => + _localizedStrings['labelEventPrice'] ?? 'Event Price'; + String get hintEventPrice => + _localizedStrings['hintEventPrice'] ?? 'Enter discounted price'; String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category'; - + // 기타 번역 - String get subscription => _localizedStrings['subscription'] ?? 'Subscription'; + String get subscription => + _localizedStrings['subscription'] ?? 'Subscription'; String get movie => _localizedStrings['movie'] ?? 'Movie'; - String get music => _localizedStrings['music'] ?? 'Music'; + String get music => _localizedStrings['music'] ?? 'Music'; String get exercise => _localizedStrings['exercise'] ?? 'Exercise'; String get shopping => _localizedStrings['shopping'] ?? 'Shopping'; String get currency => _localizedStrings['currency'] ?? 'Currency'; - String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information'; - String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide'; - String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.'; - String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page'; - String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name'; + String get websiteInfo => + _localizedStrings['websiteInfo'] ?? 'Website Information'; + String get cancelGuide => + _localizedStrings['cancelGuide'] ?? 'Cancellation Guide'; + String get cancelServiceGuide => + _localizedStrings['cancelServiceGuide'] ?? + 'To cancel this service, please go to the cancellation page through the link below.'; + String get goToCancelPage => + _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page'; + String get urlAutoMatchInfo => + _localizedStrings['urlAutoMatchInfo'] ?? + 'If URL is empty, it will be automatically matched based on the service name'; String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select'; - + // 새로 추가된 getter들 - String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information'; - String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription'; - String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information'; - String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription'; - String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name'; - String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount'; - String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; + String get serviceInfo => + _localizedStrings['serviceInfo'] ?? 'Service Information'; + String get newSubscriptionAdd => + _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription'; + String get enterServiceInfo => + _localizedStrings['enterServiceInfo'] ?? 'Enter service information'; + String get addSubscriptionButton => + _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription'; + String get serviceNameRequired => + _localizedStrings['serviceNameRequired'] ?? 'Please enter service name'; + String get amountRequired => + _localizedStrings['amountRequired'] ?? 'Please enter amount'; + String get subscriptionDetail => + _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount'; - String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; - String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; - + String get invalidAmount => + _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; + String get featureComingSoon => + _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; + // 결제 주기를 키값으로 변환하여 번역된 이름 반환 String getBillingCycleName(String billingCycleKey) { switch (billingCycleKey) { @@ -433,7 +621,7 @@ class AppLocalizations { return billingCycleKey; // 매칭되지 않으면 원본 반환 } } - + // 카테고리 이름을 키로 변환하여 번역된 이름 반환 String getCategoryName(String categoryKey) { switch (categoryKey) { @@ -467,7 +655,8 @@ class AppLocalizationsDelegate extends LocalizationsDelegate { const AppLocalizationsDelegate(); @override - bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode); @override Future load(Locale locale) async { diff --git a/lib/main.dart b/lib/main.dart index 590296a..0bad487 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,11 +22,12 @@ import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'dart:io' show Platform; import 'dart:async' show unawaited; import 'utils/memory_manager.dart'; +import 'utils/logger.dart'; import 'utils/performance_optimizer.dart'; import 'navigator_key.dart'; // AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경) -const bool enableAdMob = false; +const bool enableAdMob = true; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -44,16 +45,23 @@ Future main() async { try { // 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비 - // 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지) - await DefaultCacheManager().emptyCache(); + // 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화 + // 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요. + const bool clearCacheOnStartup = bool.fromEnvironment( + 'CLEAR_CACHE_ON_STARTUP', + defaultValue: false, + ); + if (clearCacheOnStartup) { + await DefaultCacheManager().emptyCache(); + } if (kDebugMode) { - print('이미지 캐시 관리 초기화 완료'); + Log.d('이미지 캐시 관리 초기화 완료'); PerformanceOptimizer.checkConstOptimization(); } } catch (e) { if (kDebugMode) { - print('캐시 초기화 오류: $e'); + Log.e('캐시 초기화 오류', e); } } diff --git a/lib/models/subscription_model.dart b/lib/models/subscription_model.dart index 594c7ce..c5ec3d1 100644 --- a/lib/models/subscription_model.dart +++ b/lib/models/subscription_model.dart @@ -75,7 +75,7 @@ class SubscriptionModel extends HiveObject { if (!isEventActive || eventStartDate == null || eventEndDate == null) { return false; } - + final now = DateTime.now(); return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!); } @@ -98,7 +98,7 @@ class SubscriptionModel extends HiveObject { // 원래 가격 (이벤트와 관계없이 항상 정상 가격) double get originalPrice => monthlyCost; - + // 결제 주기를 영어 키값으로 정규화 static String normalizeBillingCycle(String cycle) { switch (cycle.toLowerCase()) { @@ -121,7 +121,7 @@ class SubscriptionModel extends HiveObject { return 'monthly'; // 기본값은 monthly } } - + // 결제 주기를 영어 키값으로 반환 (내부 사용) String get billingCycleKey => normalizeBillingCycle(billingCycle); } diff --git a/lib/navigation/app_navigation_observer.dart b/lib/navigation/app_navigation_observer.dart index 3727f99..82db49d 100644 --- a/lib/navigation/app_navigation_observer.dart +++ b/lib/navigation/app_navigation_observer.dart @@ -37,22 +37,24 @@ class AppNavigationObserver extends NavigatorObserver { if (newRoute != null) { _updateNavigationState(newRoute); } - debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); + debugPrint( + 'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); } void _updateNavigationState(Route route) { if (navigator?.context == null) return; - + final routeName = route.settings.name; if (routeName == null) return; - + // build 완료 후 업데이트하도록 변경 WidgetsBinding.instance.addPostFrameCallback((_) { if (navigator?.context == null) return; - + try { final context = navigator!.context; - final navigationProvider = Provider.of(context, listen: false); + final navigationProvider = + Provider.of(context, listen: false); navigationProvider.updateByRoute(routeName); } catch (e) { debugPrint('Failed to update navigation state: $e'); @@ -62,18 +64,19 @@ class AppNavigationObserver extends NavigatorObserver { void _handlePopWithProvider() { if (navigator?.context == null) return; - + // build 완료 후 업데이트하도록 변경 WidgetsBinding.instance.addPostFrameCallback((_) { if (navigator?.context == null) return; - + try { final context = navigator!.context; - final navigationProvider = Provider.of(context, listen: false); + final navigationProvider = + Provider.of(context, listen: false); navigationProvider.pop(); } catch (e) { debugPrint('Failed to handle pop with provider: $e'); } }); } -} \ No newline at end of file +} diff --git a/lib/providers/category_provider.dart b/lib/providers/category_provider.dart index 585eadf..57ceed6 100644 --- a/lib/providers/category_provider.dart +++ b/lib/providers/category_provider.dart @@ -28,14 +28,14 @@ class CategoryProvider extends ChangeNotifier { sortedCategories.sort((a, b) { final aIndex = _categoryOrder.indexOf(a.name); final bIndex = _categoryOrder.indexOf(b.name); - + // 순서 목록에 없는 카테고리는 맨 뒤로 if (aIndex == -1) return 1; if (bIndex == -1) return -1; - + return aIndex.compareTo(bIndex); }); - + return sortedCategories; } @@ -59,9 +59,17 @@ class CategoryProvider extends ChangeNotifier { {'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'}, {'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'}, {'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'}, - {'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'}, + { + 'name': 'shoppingEcommerce', + 'color': '#FF9800', + 'icon': 'shopping_cart' + }, {'name': 'programming', 'color': '#795548', 'icon': 'code'}, - {'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'}, + { + 'name': 'collaborationOffice', + 'color': '#607D8B', + 'icon': 'business_center' + }, {'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'}, {'name': 'other', 'color': '#9E9E9E', 'icon': 'category'}, ]; @@ -117,7 +125,7 @@ class CategoryProvider extends ChangeNotifier { return null; } } - + // 카테고리 이름을 현재 언어에 맞게 반환 String getLocalizedCategoryName(BuildContext context, String categoryKey) { final localizations = AppLocalizations.of(context); diff --git a/lib/providers/locale_provider.dart b/lib/providers/locale_provider.dart index 36dcb49..48c844f 100644 --- a/lib/providers/locale_provider.dart +++ b/lib/providers/locale_provider.dart @@ -5,24 +5,24 @@ import 'dart:ui' as ui; class LocaleProvider extends ChangeNotifier { late Box _localeBox; Locale _locale = const Locale('ko'); - + static const List supportedLanguages = ['en', 'ko', 'ja', 'zh']; Locale get locale => _locale; Future init() async { _localeBox = await Hive.openBox('locale'); - + // 저장된 언어 설정 확인 final savedLocale = _localeBox.get('locale'); - + if (savedLocale != null) { // 저장된 언어가 있으면 사용 _locale = Locale(savedLocale); } else { // 저장된 언어가 없으면 시스템 언어 감지 final systemLocale = ui.PlatformDispatcher.instance.locale; - + // 시스템 언어가 지원되는 언어인지 확인 if (supportedLanguages.contains(systemLocale.languageCode)) { _locale = Locale(systemLocale.languageCode); @@ -30,11 +30,11 @@ class LocaleProvider extends ChangeNotifier { // 지원되지 않는 언어면 영어 사용 _locale = const Locale('en'); } - + // 감지된 언어 저장 await _localeBox.put('locale', _locale.languageCode); } - + notifyListeners(); } diff --git a/lib/providers/navigation_provider.dart b/lib/providers/navigation_provider.dart index 584b9fe..011d619 100644 --- a/lib/providers/navigation_provider.dart +++ b/lib/providers/navigation_provider.dart @@ -36,25 +36,25 @@ class NavigationProvider extends ChangeNotifier { void updateCurrentIndex(int index, {bool addToHistory = true}) { if (_currentIndex == index) return; - + _currentIndex = index; _currentRoute = indexToRoute[index] ?? '/'; _currentTitle = indexToTitle[index] ?? 'home'; - + if (addToHistory && index >= 0) { _navigationHistory.add(index); if (_navigationHistory.length > 10) { _navigationHistory.removeAt(0); } } - + notifyListeners(); } void updateByRoute(String route) { final index = routeToIndex[route] ?? 0; _currentRoute = route; - + if (index >= 0) { _currentIndex = index; _currentTitle = indexToTitle[index] ?? 'home'; @@ -70,7 +70,7 @@ class NavigationProvider extends ChangeNotifier { _currentTitle = 'home'; } } - + notifyListeners(); } @@ -103,4 +103,4 @@ class NavigationProvider extends ChangeNotifier { _navigationHistory.add(0); notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index d790852..de6a0f5 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -86,12 +86,12 @@ class NotificationProvider extends ChangeNotifier { try { _isEnabled = value; await NotificationService.setNotificationEnabled(value); - + // 첫 권한 부여 시 기본 설정 적용 if (value) { await initializeDefaultSettingsOnFirstPermission(); } - + notifyListeners(); } catch (e) { debugPrint('알림 활성화 설정 중 오류 발생: $e'); @@ -270,15 +270,17 @@ class NotificationProvider extends ChangeNotifier { // 첫 권한 부여 시 기본 설정 초기화 Future initializeDefaultSettingsOnFirstPermission() async { try { - final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey); + final firstGranted = + await _secureStorage.read(key: _firstPermissionGrantedKey); if (firstGranted != 'true') { // 첫 권한 부여 시 기본값 설정 await setReminderDays(2); // 2일 전 알림 await setDailyReminderEnabled(true); // 반복 알림 활성화 await setPaymentEnabled(true); // 결제 예정 알림 활성화 - + // 첫 권한 부여 플래그 저장 - await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true'); + await _secureStorage.write( + key: _firstPermissionGrantedKey, value: 'true'); } } catch (e) { debugPrint('기본 설정 초기화 중 오류 발생: $e'); diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 339d32f..7efc654 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -19,23 +19,24 @@ class SubscriptionProvider extends ChangeNotifier { double get totalMonthlyExpense { final exchangeRateService = ExchangeRateService(); - final rate = exchangeRateService.cachedUsdToKrwRate ?? - ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; - + final rate = exchangeRateService.cachedUsdToKrwRate ?? + ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; + final total = _subscriptions.fold( 0.0, (sum, subscription) { final price = subscription.currentPrice; if (subscription.currency == 'USD') { debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' - '\$${price} × ₩$rate = ₩${price * rate}'); + '\$$price × ₩$rate = ₩${price * rate}'); return sum + (price * rate); } - debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); + debugPrint( + '[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); return sum + price; }, ); - + debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: ' '${_subscriptions.length}개 구독, 총액 ₩$total'); return total; @@ -69,10 +70,10 @@ class SubscriptionProvider extends ChangeNotifier { _subscriptionBox = await Hive.openBox('subscriptions'); await refreshSubscriptions(); - + // categoryId 마이그레이션 await _migrateCategoryIds(); - + // 앱 시작 시 이벤트 상태 확인 await checkAndUpdateEventStatus(); @@ -90,11 +91,11 @@ class SubscriptionProvider extends ChangeNotifier { try { _subscriptions = _subscriptionBox.values.toList() ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate)); - + debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: ' '${_subscriptions.length}개 구독, ' '총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}'); - + notifyListeners(); } catch (e) { debugPrint('구독 목록 새로고침 중 오류 발생: $e'); @@ -139,7 +140,7 @@ class SubscriptionProvider extends ChangeNotifier { await _subscriptionBox.put(subscription.id, subscription); await refreshSubscriptions(); - + // 이벤트가 활성화된 경우 알림 스케줄 재설정 if (isEventActive && eventEndDate != null) { await _scheduleEventEndNotification(subscription); @@ -191,7 +192,6 @@ class SubscriptionProvider extends ChangeNotifier { } } - Future clearAllSubscriptions() async { _isLoading = true; notifyListeners(); @@ -217,8 +217,9 @@ class SubscriptionProvider extends ChangeNotifier { } /// 이벤트 종료 알림을 스케줄링합니다. - Future _scheduleEventEndNotification(SubscriptionModel subscription) async { - if (subscription.eventEndDate != null && + Future _scheduleEventEndNotification( + SubscriptionModel subscription) async { + if (subscription.eventEndDate != null && subscription.eventEndDate!.isAfter(DateTime.now())) { await NotificationService.scheduleNotification( id: '${subscription.id}_event_end'.hashCode, @@ -232,19 +233,18 @@ class SubscriptionProvider extends ChangeNotifier { /// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다. Future checkAndUpdateEventStatus() async { bool hasChanges = false; - + for (var subscription in _subscriptions) { // 이벤트가 종료되었지만 아직 활성화되어 있는 경우 - if (subscription.isEventActive && + if (subscription.isEventActive && subscription.eventEndDate != null && subscription.eventEndDate!.isBefore(DateTime.now())) { - subscription.isEventActive = false; await _subscriptionBox.put(subscription.id, subscription); hasChanges = true; } } - + if (hasChanges) { await refreshSubscriptions(); } @@ -253,70 +253,73 @@ class SubscriptionProvider extends ChangeNotifier { /// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) Future calculateTotalExpense({String? locale}) async { if (_subscriptions.isEmpty) return 0.0; - + // locale이 제공되지 않으면 현재 로케일 사용 - final targetCurrency = locale != null - ? CurrencyUtil.getDefaultCurrency(locale) - : 'KRW'; // 기본값 - + final targetCurrency = + locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 + debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency'); double total = 0.0; - + for (final subscription in _subscriptions) { final currentPrice = subscription.currentPrice; debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' - '${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); - + '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); + final converted = await ExchangeRateService().convertBetweenCurrencies( currentPrice, subscription.currency, targetCurrency, ); - + total += converted ?? currentPrice; } - + debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency'); return total; } /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) - Future>> getMonthlyExpenseData({String? locale}) async { + Future>> getMonthlyExpenseData( + {String? locale}) async { final now = DateTime.now(); final List> monthlyData = []; - + // locale이 제공되지 않으면 현재 로케일 사용 - final targetCurrency = locale != null - ? CurrencyUtil.getDefaultCurrency(locale) - : 'KRW'; // 기본값 - + final targetCurrency = + locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 + // 최근 6개월 데이터 생성 for (int i = 5; i >= 0; i--) { final month = DateTime(now.year, now.month - i, 1); double monthTotal = 0.0; - + // 현재 월인지 확인 - final isCurrentMonth = (month.year == now.year && month.month == now.month); - + final isCurrentMonth = + (month.year == now.year && month.month == now.month); + if (isCurrentMonth) { - debugPrint('[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); + debugPrint( + '[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); } - + // 해당 월에 활성화된 구독 계산 for (final subscription in _subscriptions) { if (isCurrentMonth) { // 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게) final cost = subscription.currentPrice; - debugPrint('[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' - '${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); - + debugPrint( + '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' + '$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); + // 통화 변환 - final converted = await ExchangeRateService().convertBetweenCurrencies( + final converted = + await ExchangeRateService().convertBetweenCurrencies( cost, subscription.currency, targetCurrency, ); - + monthTotal += converted ?? cost; } else { // 과거 월인 경우: 기존 로직 유지 @@ -324,46 +327,50 @@ class SubscriptionProvider extends ChangeNotifier { final subscriptionStartDate = subscription.nextBillingDate.subtract( Duration(days: _getBillingCycleDays(subscription.billingCycle)), ); - - if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) && + + if (subscriptionStartDate + .isBefore(DateTime(month.year, month.month + 1, 1)) && subscription.nextBillingDate.isAfter(month)) { // 해당 월의 비용 계산 (이벤트 가격 고려) double cost; - - if (subscription.isEventActive && + + if (subscription.isEventActive && subscription.eventStartDate != null && subscription.eventEndDate != null && // 이벤트 기간과 해당 월이 겹치는지 확인 - subscription.eventStartDate!.isBefore(DateTime(month.year, month.month + 1, 1)) && + subscription.eventStartDate! + .isBefore(DateTime(month.year, month.month + 1, 1)) && subscription.eventEndDate!.isAfter(month)) { cost = subscription.eventPrice ?? subscription.monthlyCost; } else { cost = subscription.monthlyCost; } - + // 통화 변환 - final converted = await ExchangeRateService().convertBetweenCurrencies( + final converted = + await ExchangeRateService().convertBetweenCurrencies( cost, subscription.currency, targetCurrency, ); - + monthTotal += converted ?? cost; } } } - + if (isCurrentMonth) { - debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency'); + debugPrint( + '[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency'); } - + monthlyData.add({ 'month': month, 'totalExpense': monthTotal, 'monthName': _getMonthLabel(month, locale ?? 'en'), }); } - + return monthlyData; } @@ -409,98 +416,109 @@ class SubscriptionProvider extends ChangeNotifier { /// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당 Future _migrateCategoryIds() async { debugPrint('❎ CategoryId 마이그레이션 시작...'); - + final categoryProvider = CategoryProvider(); await categoryProvider.init(); final categories = categoryProvider.categories; - + int migratedCount = 0; - + for (var subscription in _subscriptions) { if (subscription.categoryId == null) { final serviceName = subscription.serviceName.toLowerCase(); String? categoryId; - + debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...'); - + // OTT 서비스 - if (serviceName.contains('netflix') || - serviceName.contains('youtube') || + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || serviceName.contains('disney') || serviceName.contains('왓차') || serviceName.contains('티빙') || serviceName.contains('디즈니') || serviceName.contains('넷플릭스')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'OTT 서비스', - orElse: () => categories.first, - ).id; + categoryId = categories + .firstWhere( + (cat) => cat.name == 'OTT 서비스', + orElse: () => categories.first, + ) + .id; } // 음악 서비스 - else if (serviceName.contains('spotify') || - serviceName.contains('apple music') || - serviceName.contains('멜론') || - serviceName.contains('지니') || - serviceName.contains('플로') || - serviceName.contains('벡스')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'music', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('멜론') || + serviceName.contains('지니') || + serviceName.contains('플로') || + serviceName.contains('벡스')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'music', + orElse: () => categories.first, + ) + .id; } // AI 서비스 - else if (serviceName.contains('chatgpt') || - serviceName.contains('claude') || - serviceName.contains('midjourney') || - serviceName.contains('copilot')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'aiService', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('chatgpt') || + serviceName.contains('claude') || + serviceName.contains('midjourney') || + serviceName.contains('copilot')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'aiService', + orElse: () => categories.first, + ) + .id; } // 프로그래밍/개발 - else if (serviceName.contains('github') || - serviceName.contains('intellij') || - serviceName.contains('webstorm') || - serviceName.contains('jetbrains')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'programming', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('github') || + serviceName.contains('intellij') || + serviceName.contains('webstorm') || + serviceName.contains('jetbrains')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'programming', + orElse: () => categories.first, + ) + .id; } // 오피스/협업 툴 - else if (serviceName.contains('notion') || - serviceName.contains('microsoft') || - serviceName.contains('office') || - serviceName.contains('slack') || - serviceName.contains('figma') || - serviceName.contains('icloud') || - serviceName.contains('아이클라우드')) { - categoryId = categories.firstWhere( - (cat) => cat.name == 'collaborationOffice', - orElse: () => categories.first, - ).id; + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('slack') || + serviceName.contains('figma') || + serviceName.contains('icloud') || + serviceName.contains('아이클라우드')) { + categoryId = categories + .firstWhere( + (cat) => cat.name == 'collaborationOffice', + orElse: () => categories.first, + ) + .id; } // 기타 서비스 (기본값) else { - categoryId = categories.firstWhere( - (cat) => cat.name == 'other', - orElse: () => categories.first, - ).id; - } - - if (categoryId != null) { - subscription.categoryId = categoryId; - await subscription.save(); - migratedCount++; - final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name; - debugPrint('✅ ${subscription.serviceName} → $categoryName'); + categoryId = categories + .firstWhere( + (cat) => cat.name == 'other', + orElse: () => categories.first, + ) + .id; } + + subscription.categoryId = categoryId; + await subscription.save(); + migratedCount++; + final categoryName = + categories.firstWhere((cat) => cat.id == categoryId).name; + debugPrint('✅ ${subscription.serviceName} → $categoryName'); } } - + if (migratedCount > 0) { - debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료'); + debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료'); await refreshSubscriptions(); } else { debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 412fecb..2979d57 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -7,24 +7,24 @@ import '../theme/adaptive_theme.dart'; class ThemeProvider extends ChangeNotifier { static const String _themeBoxName = 'theme_settings'; static const String _themeKey = 'theme_settings'; - + late Box _themeBox; ThemeSettings _themeSettings = const ThemeSettings(); - + ThemeSettings get themeSettings => _themeSettings; - + AppThemeMode get themeMode => _themeSettings.mode; bool get useSystemColors => _themeSettings.useSystemColors; bool get largeText => _themeSettings.largeText; bool get reduceMotion => _themeSettings.reduceMotion; bool get highContrast => _themeSettings.highContrast; - + /// Provider 초기화 Future initialize() async { _themeBox = await Hive.openBox(_themeBoxName); await _loadThemeSettings(); } - + /// 저장된 테마 설정 로드 Future _loadThemeSettings() async { final savedSettings = _themeBox.get(_themeKey); @@ -35,53 +35,53 @@ class ThemeProvider extends ChangeNotifier { notifyListeners(); } } - + /// 테마 설정 저장 Future _saveThemeSettings() async { await _themeBox.put(_themeKey, _themeSettings.toJson()); } - + /// 테마 모드 변경 Future setThemeMode(AppThemeMode mode) async { _themeSettings = _themeSettings.copyWith(mode: mode); await _saveThemeSettings(); notifyListeners(); } - + /// 시스템 색상 사용 설정 Future setUseSystemColors(bool value) async { _themeSettings = _themeSettings.copyWith(useSystemColors: value); await _saveThemeSettings(); notifyListeners(); } - + /// 큰 텍스트 설정 Future setLargeText(bool value) async { _themeSettings = _themeSettings.copyWith(largeText: value); await _saveThemeSettings(); notifyListeners(); } - + /// 모션 감소 설정 Future setReduceMotion(bool value) async { _themeSettings = _themeSettings.copyWith(reduceMotion: value); await _saveThemeSettings(); notifyListeners(); } - + /// 고대비 설정 Future setHighContrast(bool value) async { _themeSettings = _themeSettings.copyWith(highContrast: value); await _saveThemeSettings(); notifyListeners(); } - + /// 현재 설정에 따른 테마 가져오기 ThemeData getTheme(BuildContext context) { final platformBrightness = MediaQuery.of(context).platformBrightness; - + ThemeData baseTheme; - + switch (_themeSettings.mode) { case AppThemeMode.light: baseTheme = AdaptiveTheme.lightTheme; @@ -98,7 +98,7 @@ class ThemeProvider extends ChangeNotifier { : AdaptiveTheme.lightTheme; break; } - + // 접근성 설정 적용 return AdaptiveTheme.getAccessibleTheme( baseTheme, @@ -107,11 +107,11 @@ class ThemeProvider extends ChangeNotifier { highContrast: _themeSettings.highContrast, ); } - + /// 현재 테마가 다크 모드인지 확인 bool isDarkMode(BuildContext context) { final platformBrightness = MediaQuery.of(context).platformBrightness; - + switch (_themeSettings.mode) { case AppThemeMode.light: return false; @@ -122,7 +122,7 @@ class ThemeProvider extends ChangeNotifier { return platformBrightness == Brightness.dark; } } - + /// 테마 토글 (라이트/다크) Future toggleTheme() async { if (_themeSettings.mode == AppThemeMode.light) { @@ -137,7 +137,7 @@ class ThemeProvider extends ChangeNotifier { class AnimatedThemeBuilder extends StatelessWidget { final Widget Function(BuildContext, ThemeData) builder; final Duration duration; - + const AnimatedThemeBuilder({ super.key, required this.builder, @@ -148,7 +148,7 @@ class AnimatedThemeBuilder extends StatelessWidget { Widget build(BuildContext context) { final themeProvider = context.watch(); final theme = themeProvider.getTheme(context); - + return AnimatedTheme( data: theme, duration: duration, @@ -164,7 +164,7 @@ class ThemedColor extends StatelessWidget { final Color lightColor; final Color darkColor; final Widget child; - + const ThemedColor({ super.key, required this.lightColor, @@ -175,7 +175,7 @@ class ThemedColor extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = context.read().isDarkMode(context); - + return Theme( data: Theme.of(context).copyWith( primaryColor: isDark ? darkColor : lightColor, @@ -183,4 +183,4 @@ class ThemedColor extends StatelessWidget { child: child, ); } -} \ No newline at end of file +} diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 8346ea9..882a24f 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -6,6 +6,7 @@ import 'package:submanager/screens/sms_scan_screen.dart'; import 'package:submanager/screens/analysis_screen.dart'; import 'package:submanager/screens/settings_screen.dart'; import 'package:submanager/screens/splash_screen.dart'; +import 'package:submanager/screens/sms_permission_screen.dart'; import 'package:submanager/models/subscription_model.dart'; class AppRoutes { @@ -16,6 +17,7 @@ class AppRoutes { static const String smsScanner = '/sms-scanner'; static const String analysis = '/analysis'; static const String settings = '/settings'; + static const String smsPermission = '/sms-permission'; static Map getRoutes() { return { @@ -25,6 +27,7 @@ class AppRoutes { smsScanner: (context) => const SmsScanScreen(), analysis: (context) => const AnalysisScreen(), settings: (context) => const SettingsScreen(), + smsPermission: (context) => const SmsPermissionScreen(), }; } @@ -32,29 +35,33 @@ class AppRoutes { switch (routeSettings.name) { case splash: return _buildRoute(const SplashScreen(), routeSettings); - + case main: return _buildRoute(const MainScreen(), routeSettings); - + case addSubscription: return _buildRoute(const AddSubscriptionScreen(), routeSettings); - + case subscriptionDetail: final subscription = routeSettings.arguments as SubscriptionModel?; if (subscription != null) { - return _buildRoute(DetailScreen(subscription: subscription), routeSettings); + return _buildRoute( + DetailScreen(subscription: subscription), routeSettings); } return _errorRoute(); - + case smsScanner: return _buildRoute(const SmsScanScreen(), routeSettings); - + case analysis: return _buildRoute(const AnalysisScreen(), routeSettings); - + case settings: return _buildRoute(const SettingsScreen(), routeSettings); - + + case smsPermission: + return _buildRoute(const SmsPermissionScreen(), routeSettings); + default: return _errorRoute(); } @@ -77,15 +84,18 @@ class AppRoutes { ); } - static void navigateTo(BuildContext context, String routeName, {Object? arguments}) { + static void navigateTo(BuildContext context, String routeName, + {Object? arguments}) { Navigator.pushNamed(context, routeName, arguments: arguments); } - static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) { + static void navigateAndReplace(BuildContext context, String routeName, + {Object? arguments}) { Navigator.pushReplacementNamed(context, routeName, arguments: arguments); } - static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) { + static void navigateAndRemoveUntil(BuildContext context, String routeName, + {Object? arguments}) { Navigator.pushNamedAndRemoveUntil( context, routeName, @@ -103,4 +113,4 @@ class AppRoutes { static bool canPop(BuildContext context) { return Navigator.canPop(context); } -} \ No newline at end of file +} diff --git a/lib/screens/add_subscription_screen.dart b/lib/screens/add_subscription_screen.dart index c407572..46ce537 100644 --- a/lib/screens/add_subscription_screen.dart +++ b/lib/screens/add_subscription_screen.dart @@ -62,14 +62,14 @@ class _AddSubscriptionScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: MediaQuery.of(context).padding.top + 60), - + // 헤더 섹션 AddSubscriptionHeader( controller: _controller, fadeAnimation: _controller.fadeAnimation!, slideAnimation: _controller.slideAnimation!, ), - + // 서비스 정보 폼 AddSubscriptionForm( controller: _controller, @@ -78,7 +78,7 @@ class _AddSubscriptionScreenState extends State setState: setState, ), const SizedBox(height: 16), - + // 이벤트/할인 섹션 AddSubscriptionEventSection( controller: _controller, @@ -87,7 +87,7 @@ class _AddSubscriptionScreenState extends State setState: setState, ), const SizedBox(height: 32), - + // 저장 버튼 AddSubscriptionSaveButton( controller: _controller, @@ -101,4 +101,4 @@ class _AddSubscriptionScreenState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index 9d2943a..8d3ae18 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -43,12 +43,14 @@ class _AnalysisScreenState extends State // Provider 변경 감지 final provider = Provider.of(context); final currentHash = _calculateDataHash(provider); - + debugPrint('[AnalysisScreen] didChangeDependencies: ' '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); - + // 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드 - if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) { + if (currentHash != _lastDataHash && + !_isLoading && + _lastDataHash.isNotEmpty) { debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작'); _loadData(); } @@ -65,15 +67,16 @@ class _AnalysisScreenState extends State String _calculateDataHash(SubscriptionProvider provider) { final subscriptions = provider.subscriptions; final buffer = StringBuffer(); - + buffer.write(subscriptions.length); buffer.write('_'); buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2)); - + for (final sub in subscriptions) { - buffer.write('_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}'); + buffer.write( + '_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}'); } - + return buffer.toString(); } @@ -148,7 +151,7 @@ class _AnalysisScreenState extends State height: kToolbarHeight + MediaQuery.of(context).padding.top, ), ), - + // 네이티브 광고 위젯 SliverToBoxAdapter( child: _buildAnimatedAd(), @@ -166,7 +169,7 @@ class _AnalysisScreenState extends State // 2. 총 지출 요약 카드 TotalExpenseSummaryCard( - key: ValueKey('total_expense_${_lastDataHash}'), + key: ValueKey('total_expense_$_lastDataHash'), subscriptions: subscriptions, totalExpense: _totalExpense, animationController: _animationController, @@ -176,7 +179,7 @@ class _AnalysisScreenState extends State // 3. 월별 지출 차트 MonthlyExpenseChartCard( - key: ValueKey('monthly_expense_${_lastDataHash}'), + key: ValueKey('monthly_expense_$_lastDataHash'), monthlyData: _monthlyData, animationController: _animationController, ), @@ -197,4 +200,4 @@ class _AnalysisScreenState extends State ], ); } -} \ No newline at end of file +} diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart index 1e139a7..db8bd89 100644 --- a/lib/screens/app_lock_screen.dart +++ b/lib/screens/app_lock_screen.dart @@ -13,13 +13,13 @@ class AppLockScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.lock_outline, size: 80, color: AppColors.navyGray, ), const SizedBox(height: 24), - Text( + const Text( '앱이 잠겨 있습니다', style: TextStyle( fontSize: 24, @@ -28,7 +28,7 @@ class AppLockScreen extends StatelessWidget { ), ), const SizedBox(height: 16), - Text( + const Text( '생체 인증으로 잠금을 해제하세요', style: TextStyle( fontSize: 16, @@ -42,7 +42,7 @@ class AppLockScreen extends StatelessWidget { final success = await appLock.authenticate(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text( '인증에 실패했습니다. 다시 시도해주세요.', style: TextStyle( diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index c7c590d..ba4d9b1 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text( + title: const Text( '카테고리 관리', style: TextStyle( color: AppColors.pureWhite, @@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State { children: [ TextFormField( controller: _nameController, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: '카테고리 이름', labelStyle: TextStyle( color: AppColors.navyGray, @@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), DropdownButtonFormField( value: _selectedColor, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: '색상 선택', labelStyle: TextStyle( color: AppColors.navyGray, @@ -90,15 +90,35 @@ class _CategoryManagementScreenState extends State { ), items: [ DropdownMenuItem( - value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))), + value: '#1976D2', + child: Text( + AppLocalizations.of(context).colorBlue, + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( - value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))), + value: '#4CAF50', + child: Text( + AppLocalizations.of(context).colorGreen, + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( - value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))), + value: '#FF9800', + child: Text( + AppLocalizations.of(context).colorOrange, + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( - value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))), + value: '#F44336', + child: Text( + AppLocalizations.of(context).colorRed, + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( - value: '#9C27B0', child: Text(AppLocalizations.of(context).colorPurple, style: TextStyle(color: AppColors.darkNavy))), + value: '#9C27B0', + child: Text( + AppLocalizations.of(context).colorPurple, + style: const TextStyle( + color: AppColors.darkNavy))), ], onChanged: (value) { setState(() { @@ -109,22 +129,38 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), DropdownButtonFormField( value: _selectedIcon, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: '아이콘 선택', labelStyle: TextStyle( color: AppColors.navyGray, ), ), - items: [ + items: const [ DropdownMenuItem( - value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))), - DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))), + value: 'subscriptions', + child: Text('구독', + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))), + value: 'movie', + child: Text('영화', + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))), + value: 'music_note', + child: Text('음악', + style: + TextStyle(color: AppColors.darkNavy))), DropdownMenuItem( - value: 'shopping_cart', child: Text('쇼핑', style: TextStyle(color: AppColors.darkNavy))), + value: 'fitness_center', + child: Text('운동', + style: + TextStyle(color: AppColors.darkNavy))), + DropdownMenuItem( + value: 'shopping_cart', + child: Text('쇼핑', + style: + TextStyle(color: AppColors.darkNavy))), ], onChanged: (value) { setState(() { @@ -135,7 +171,7 @@ class _CategoryManagementScreenState extends State { const SizedBox(height: 16), ElevatedButton( onPressed: _addCategory, - child: Text( + child: const Text( '카테고리 추가', style: TextStyle( color: AppColors.pureWhite, @@ -163,8 +199,9 @@ class _CategoryManagementScreenState extends State { int.parse(category.color.replaceAll('#', '0xFF'))), ), title: Text( - provider.getLocalizedCategoryName(context, category.name), - style: TextStyle( + provider.getLocalizedCategoryName( + context, category.name), + style: const TextStyle( color: AppColors.darkNavy, ), ), diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index a430af2..7dab203 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -43,7 +43,6 @@ class _DetailScreenState extends State super.dispose(); } - @override Widget build(BuildContext context) { final baseColor = _controller.getCardColor(); @@ -53,111 +52,112 @@ class _DetailScreenState extends State child: Scaffold( backgroundColor: AppColors.backgroundColor, body: CustomScrollView( - controller: _controller.scrollController, - slivers: [ - // 상단 헤더 섹션 - SliverToBoxAdapter( - child: DetailHeaderSection( - subscription: widget.subscription, - controller: _controller, - fadeAnimation: _controller.fadeAnimation!, - slideAnimation: _controller.slideAnimation!, - rotateAnimation: _controller.rotateAnimation!, - ), - ), - // 본문 콘텐츠 - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - // 편집 모드 안내 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - baseColor.withValues(alpha: 0.15), - baseColor.withValues(alpha: 0.08), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: baseColor.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Row( - children: [ - Icon( - Icons.edit_rounded, - color: baseColor, - size: 20, - ), - const SizedBox(width: 8), - Text( - AppLocalizations.of(context).editMode, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: baseColor, - ), - ), - const Spacer(), - Text( - AppLocalizations.of(context).changesAppliedAfterSave, - style: TextStyle( - fontSize: 14, - color: AppColors.darkNavy, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // 기본 정보 폼 섹션 - DetailFormSection( - controller: _controller, - fadeAnimation: _controller.fadeAnimation!, - slideAnimation: _controller.slideAnimation!, - ), - const SizedBox(height: 16), - - // 이벤트 가격 섹션 - DetailEventSection( - controller: _controller, - fadeAnimation: _controller.fadeAnimation!, - slideAnimation: _controller.slideAnimation!, - ), - const SizedBox(height: 16), - - // 웹사이트 URL 섹션 - DetailUrlSection( - controller: _controller, - fadeAnimation: _controller.fadeAnimation!, - slideAnimation: _controller.slideAnimation!, - ), - const SizedBox(height: 32), - - // 액션 버튼 - DetailActionButtons( - controller: _controller, - fadeAnimation: _controller.fadeAnimation!, - slideAnimation: _controller.slideAnimation!, - ), - ], + controller: _controller.scrollController, + slivers: [ + // 상단 헤더 섹션 + SliverToBoxAdapter( + child: DetailHeaderSection( + subscription: widget.subscription, + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + rotateAnimation: _controller.rotateAnimation!, ), ), - ), - ], + // 본문 콘텐츠 + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // 편집 모드 안내 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + baseColor.withValues(alpha: 0.15), + baseColor.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: baseColor.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.edit_rounded, + color: baseColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context).editMode, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: baseColor, + ), + ), + const Spacer(), + Text( + AppLocalizations.of(context) + .changesAppliedAfterSave, + style: const TextStyle( + fontSize: 14, + color: AppColors.darkNavy, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 기본 정보 폼 섹션 + DetailFormSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 16), + + // 이벤트 가격 섹션 + DetailEventSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 16), + + // 웹사이트 URL 섹션 + DetailUrlSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 32), + + // 액션 버튼 + DetailActionButtons( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + ], + ), + ), + ), + ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 3c3cf15..6d87d46 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -33,7 +33,7 @@ class _MainScreenState extends State late AnimationController _waveController; late ScrollController _scrollController; late FloatingNavBarScrollController _navBarScrollController; - + // 화면 목록 late final List _screens; @@ -63,7 +63,7 @@ class _MainScreenState extends State ); _scrollController = ScrollController(); - + _navBarScrollController = FloatingNavBarScrollController( scrollController: _scrollController, onHide: () {}, @@ -157,7 +157,7 @@ class _MainScreenState extends State AppRoutes.addSubscription, ).then((result) { _resetAnimations(); - + // 구독이 성공적으로 추가된 경우 if (result == true) { // 상단에 스낵바 표시 @@ -203,18 +203,18 @@ class _MainScreenState extends State void _handleNavigation(int index, BuildContext context) { final navigationProvider = context.read(); - + // 이미 같은 인덱스면 무시 if (navigationProvider.currentIndex == index) { return; } - + // 추가 버튼은 별도 처리 if (index == 2) { _navigateToAddSubscription(context); return; } - + // 인덱스 업데이트 navigationProvider.updateCurrentIndex(index); } @@ -222,7 +222,7 @@ class _MainScreenState extends State @override Widget build(BuildContext context) { final navigationProvider = context.watch(); - + // 메인 그라데이션 사용 List backgroundGradient = AppColors.mainGradient; @@ -235,8 +235,12 @@ class _MainScreenState extends State return GlassmorphicScaffold( body: IndexedStack( index: PlatformHelper.isIOS - ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 - : (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직 + ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 + : (currentIndex == 3 + ? 3 + : currentIndex == 4 + ? 4 + : currentIndex), // Android: 기존 로직 children: _screens, ), backgroundGradient: backgroundGradient, @@ -249,4 +253,4 @@ class _MainScreenState extends State enableWaveAnimation: false, ); } -} \ No newline at end of file +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8c92407..0d4ceb6 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -5,13 +5,14 @@ import '../providers/notification_provider.dart'; import 'dart:io'; import '../services/notification_service.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../theme/adaptive_theme.dart'; import '../widgets/glassmorphism_card.dart'; import '../theme/app_colors.dart'; import '../widgets/native_ad_widget.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; import '../providers/locale_provider.dart'; +import 'package:permission_handler/permission_handler.dart' as permission; +import '../services/sms_service.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -228,6 +229,7 @@ class SettingsScreen extends StatelessWidget { if (granted) { await provider.setEnabled(true); } else { + if (!context.mounted) return; AppSnackBar.showError( context: context, message: AppLocalizations.of(context) @@ -271,7 +273,7 @@ class SettingsScreen extends StatelessWidget { elevation: 0, color: Theme.of(context) .colorScheme - .surfaceVariant + .surfaceContainerHighest .withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -412,7 +414,7 @@ class SettingsScreen extends StatelessWidget { decoration: BoxDecoration( color: Theme.of(context) .colorScheme - .surfaceVariant + .surfaceContainerHighest .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), @@ -476,6 +478,89 @@ class SettingsScreen extends StatelessWidget { ), ), + // SMS 권한 설정 + if (!kIsWeb && Platform.isAndroid) + GlassmorphismCard( + margin: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), + child: FutureBuilder( + future: permission.Permission.sms.status, + builder: (context, snapshot) { + final isLoading = + snapshot.connectionState == ConnectionState.waiting; + final status = snapshot.data; + final hasPermission = status?.isGranted ?? false; + final isPermanentlyDenied = + status?.isPermanentlyDenied ?? false; + + return ListTile( + leading: const Icon( + Icons.sms, + color: AppColors.textSecondary, + ), + title: Text( + AppLocalizations.of(context).smsPermissionLabel, + style: const TextStyle(color: AppColors.textPrimary), + ), + subtitle: !hasPermission + ? Text( + isPermanentlyDenied + ? AppLocalizations.of(context) + .permanentlyDeniedMessage + : AppLocalizations.of(context) + .smsPermissionRequired, + style: const TextStyle( + color: AppColors.textSecondary), + ) + : null, + trailing: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : hasPermission + ? const Padding( + padding: + EdgeInsets.symmetric(horizontal: 8.0), + child: Icon(Icons.check_circle, + color: Colors.green), + ) + : isPermanentlyDenied + ? TextButton( + onPressed: () async { + await permission.openAppSettings(); + }, + child: Text(AppLocalizations.of(context) + .openSettings), + ) + : ElevatedButton( + onPressed: () async { + final granted = await SMSService + .requestSMSPermission(); + if (!granted) { + final newStatus = await permission + .Permission.sms.status; + if (newStatus.isPermanentlyDenied) { + await permission + .openAppSettings(); + } + } + if (context.mounted) { + (context as Element) + .markNeedsBuild(); + } + }, + child: Text(AppLocalizations.of(context) + .requestPermission), + ), + ); + }, + ), + ), + // 앱 정보 GlassmorphismCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), diff --git a/lib/screens/sms_permission_screen.dart b/lib/screens/sms_permission_screen.dart new file mode 100644 index 0000000..01486e4 --- /dev/null +++ b/lib/screens/sms_permission_screen.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart' as permission; +import '../theme/app_colors.dart'; +import '../widgets/glassmorphism_card.dart'; +import '../routes/app_routes.dart'; +import '../l10n/app_localizations.dart'; +import '../services/sms_service.dart'; +import '../utils/platform_helper.dart'; + +class SmsPermissionScreen extends StatefulWidget { + const SmsPermissionScreen({super.key}); + + @override + State createState() => _SmsPermissionScreenState(); +} + +class _SmsPermissionScreenState extends State { + bool _requesting = false; + + Future _handleRequest() async { + if (_requesting) return; + setState(() => _requesting = true); + + try { + if (!PlatformHelper.isAndroid) { + // iOS/Web은 권한 흐름 없이 메인으로 이동 + if (mounted) { + Navigator.of(context).pushReplacementNamed(AppRoutes.main); + } + return; + } + + final status = await permission.Permission.sms.status; + if (status.isGranted) { + if (mounted) { + Navigator.of(context).pushReplacementNamed(AppRoutes.main); + } + return; + } + + // 권한 요청 + final granted = await SMSService.requestSMSPermission(); + if (mounted) { + if (granted) { + Navigator.of(context).pushReplacementNamed(AppRoutes.main); + } else { + final newStatus = await permission.Permission.sms.status; + if (newStatus.isPermanentlyDenied) { + // 설정 열기 유도 + _showSettingsDialog(); + } + // 거부지만 영구 거부가 아니라면 그대로 대기 (사용자가 다시 시도 가능) + } + } + } finally { + if (mounted) setState(() => _requesting = false); + } + } + + void _showSettingsDialog() { + final loc = AppLocalizations.of(context); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(loc.smsPermissionRequired), + content: Text(loc.permanentlyDeniedMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context).cancel), + ), + TextButton( + onPressed: () async { + await permission.openAppSettings(); + if (mounted) Navigator.of(context).pop(); + }, + child: Text(loc.openSettings), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + return Scaffold( + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.sms, size: 64, color: AppColors.primaryColor), + const SizedBox(height: 16), + Text( + loc.smsPermissionTitle, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + loc.smsPermissionRequired, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + GlassmorphismCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(loc.smsPermissionReasonTitle, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(loc.smsPermissionReasonBody), + const SizedBox(height: 12), + Text(loc.smsPermissionScopeTitle, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(loc.smsPermissionScopeBody), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _requesting ? null : _handleRequest, + icon: const Icon(Icons.lock_open), + label: Text( + _requesting ? loc.requesting : loc.requestPermission), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => Navigator.of(context) + .pushReplacementNamed(AppRoutes.main), + child: Text(loc.later), + ) + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 39b5152..d3c1f7a 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../controllers/sms_scan_controller.dart'; import '../widgets/sms_scan/scan_loading_widget.dart'; import '../widgets/sms_scan/scan_initial_widget.dart'; @@ -75,7 +74,8 @@ class _SmsScanScreenState extends State { ); } - final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex]; + final currentSubscription = + _controller.scannedSubscriptions[_controller.currentIndex]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -119,4 +119,4 @@ class _SmsScanScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 16c8845..fb752e1 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; +import '../services/sms_service.dart'; +import '../utils/platform_helper.dart'; import '../routes/app_routes.dart'; import '../l10n/app_localizations.dart'; +import '../utils/reduce_motion.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -63,7 +66,8 @@ class _SplashScreenState extends State )); // 랜덤 파티클 생성 - _generateParticles(); + // 접근성(모션 축소) 고려한 파티클 생성 + _generateParticles(reduced: ReduceMotion.platform()); _animationController.forward(); @@ -73,15 +77,17 @@ class _SplashScreenState extends State }); } - void _generateParticles() { + void _generateParticles({bool reduced = false}) { final random = DateTime.now().millisecondsSinceEpoch; + final total = reduced ? 6 : 20; - for (int i = 0; i < 20; i++) { + for (int i = 0; i < total; i++) { final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기 final x = (random % 100) / 100 * 300; // 랜덤 X 위치 final y = (random % 100) / 100 * 500; // 랜덤 Y 위치 final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도 - final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간 + final duration = (random % 10) / 10 * (reduced ? 1800 : 3000) + + (reduced ? 1200 : 2000); // 축소 시 더 짧게 final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간 int colorIndex = (random + i) % AppColors.blueGradient.length; @@ -98,9 +104,20 @@ class _SplashScreenState extends State } } - void navigateToNextScreen() { - // 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동 - // 모든 이전 라우트를 제거하고 홈으로 이동 + Future navigateToNextScreen() async { + // Android에서 SMS 권한이 없으면 권한 안내 화면으로 이동 + if (PlatformHelper.isAndroid) { + final hasPermission = await SMSService.hasSMSPermission(); + if (!hasPermission && mounted) { + Navigator.of(context).pushNamedAndRemoveUntil( + AppRoutes.smsPermission, + (route) => false, + ); + return; + } + } + + if (!mounted) return; Navigator.of(context).pushNamedAndRemoveUntil( AppRoutes.main, (route) => false, @@ -244,7 +261,14 @@ class _SplashScreenState extends State BorderRadius.circular(30), child: BackdropFilter( filter: ImageFilter.blur( - sigmaX: 20, sigmaY: 20), + sigmaX: ReduceMotion.scale( + context, + normal: 20, + reduced: 8), + sigmaY: ReduceMotion.scale( + context, + normal: 20, + reduced: 8)), child: Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -269,7 +293,11 @@ class _SplashScreenState extends State color: AppColors.shadowBlack, spreadRadius: 0, - blurRadius: 30, + blurRadius: + ReduceMotion.scale( + context, + normal: 30, + reduced: 12), offset: const Offset(0, 10), ), ], @@ -385,7 +413,7 @@ class _SplashScreenState extends State width: 1, ), ), - child: CircularProgressIndicator( + child: const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation( AppColors.pureWhite), strokeWidth: 3, diff --git a/lib/services/cache_manager.dart b/lib/services/cache_manager.dart new file mode 100644 index 0000000..373dea1 --- /dev/null +++ b/lib/services/cache_manager.dart @@ -0,0 +1,97 @@ +class _CacheEntry { + final T value; + final DateTime expiresAt; + final int size; + + _CacheEntry( + {required this.value, required this.expiresAt, required this.size}); + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} + +/// 간단한 메모리 기반 캐시 매니저 (TTL/최대 개수/용량 제한) +class SimpleCacheManager { + final int maxEntries; + final int maxBytes; + final Duration ttl; + + final Map> _store = >{}; + int _currentBytes = 0; + + // 간단한 메트릭 + int _hits = 0; + int _misses = 0; + int _puts = 0; + int _evictions = 0; + + SimpleCacheManager({ + this.maxEntries = 128, + this.maxBytes = 1024 * 1024, // 1MB + this.ttl = const Duration(minutes: 30), + }); + + T? get(String key) { + final entry = _store.remove(key); + if (entry == null) return null; + if (entry.isExpired) { + _currentBytes -= entry.size; + _misses++; + return null; + } + // LRU 갱신: 재삽입으로 가장 최근으로 이동 + _store[key] = entry; + _hits++; + return entry.value; + } + + void set(String key, T value, {int size = 1, Duration? customTtl}) { + final expiresAt = DateTime.now().add(customTtl ?? ttl); + final existing = _store.remove(key); + if (existing != null) { + _currentBytes -= existing.size; + } + _store[key] = _CacheEntry(value: value, expiresAt: expiresAt, size: size); + _currentBytes += size; + _puts++; + _evictIfNeeded(); + } + + void invalidate(String key) { + final removed = _store.remove(key); + if (removed != null) { + _currentBytes -= removed.size; + } + } + + void clear() { + _store.clear(); + _currentBytes = 0; + } + + void _evictIfNeeded() { + // 개수/용량 제한을 넘으면 오래된 것부터 제거 + while (_store.length > maxEntries || _currentBytes > maxBytes) { + if (_store.isEmpty) break; + final firstKey = _store.keys.first; + final removed = _store.remove(firstKey); + if (removed != null) { + _currentBytes -= removed.size; + _evictions++; + } + } + } + + Map dumpMetrics() { + final totalGets = _hits + _misses; + final hitRate = totalGets == 0 ? 0 : _hits / totalGets; + return { + 'entries': _store.length, + 'bytes': _currentBytes, + 'hits': _hits, + 'misses': _misses, + 'hitRate': hitRate, + 'puts': _puts, + 'evictions': _evictions, + }; + } +} diff --git a/lib/services/currency_util.dart b/lib/services/currency_util.dart index 1a38f52..5d08686 100644 --- a/lib/services/currency_util.dart +++ b/lib/services/currency_util.dart @@ -1,10 +1,17 @@ import 'package:intl/intl.dart'; import '../models/subscription_model.dart'; import 'exchange_rate_service.dart'; +import 'cache_manager.dart'; /// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스 class CurrencyUtil { static final ExchangeRateService _exchangeRateService = ExchangeRateService(); + static final SimpleCacheManager _fmtCache = + SimpleCacheManager( + maxEntries: 256, + maxBytes: 256 * 1024, + ttl: const Duration(minutes: 15), + ); /// 언어에 따른 기본 통화 반환 static String getDefaultCurrency(String locale) { @@ -66,7 +73,7 @@ class CurrencyUtil { final locale = _getLocaleForCurrency(currency); final symbol = getCurrencySymbol(currency); final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2; - + return NumberFormat.currency( locale: locale, symbol: symbol, @@ -80,30 +87,46 @@ class CurrencyUtil { String currency, String locale, ) async { + // 캐시 조회 + final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2; + final key = 'fmt:$locale:$currency:${amount.toStringAsFixed(decimals)}'; + final cached = _fmtCache.get(key); + if (cached != null) return cached; + final defaultCurrency = getDefaultCurrency(locale); - + // 입력 통화가 기본 통화인 경우 if (currency == defaultCurrency) { - return _formatSingleCurrency(amount, currency); + final result = _formatSingleCurrency(amount, currency); + _fmtCache.set(key, result, size: result.length); + return result; } - + // USD 입력인 경우 - 기본 통화로 변환하여 표시 if (currency == 'USD' && defaultCurrency != 'USD') { - final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency); + final convertedAmount = await _exchangeRateService.convertUsdToTarget( + amount, defaultCurrency); if (convertedAmount != null) { - final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency); + final primaryFormatted = + _formatSingleCurrency(convertedAmount, defaultCurrency); final usdFormatted = _formatSingleCurrency(amount, 'USD'); - return '$primaryFormatted ($usdFormatted)'; + final result = '$primaryFormatted ($usdFormatted)'; + _fmtCache.set(key, result, size: result.length); + return result; } } - + // 영어 사용자가 KRW 선택한 경우 if (locale == 'en' && currency == 'KRW') { - return _formatSingleCurrency(amount, currency); + final result = _formatSingleCurrency(amount, currency); + _fmtCache.set(key, result, size: result.length); + return result; } - + // 기타 통화 입력인 경우 - return _formatSingleCurrency(amount, currency); + final result = _formatSingleCurrency(amount, currency); + _fmtCache.set(key, result, size: result.length); + return result; } /// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로) @@ -116,13 +139,13 @@ class CurrencyUtil { for (var subscription in subscriptions) { final price = subscription.currentPrice; - + final converted = await _exchangeRateService.convertBetweenCurrencies( price, subscription.currency, defaultCurrency, ); - + total += converted ?? price; } @@ -139,7 +162,20 @@ class CurrencyUtil { static Future formatSubscriptionAmountWithLocale( SubscriptionModel subscription, String locale) async { final price = subscription.currentPrice; - return formatAmountWithLocale(price, subscription.currency, locale); + // 구독 단위 캐시 키 (통화/가격/locale + id) + final decimals = + (subscription.currency == 'KRW' || subscription.currency == 'JPY') + ? 0 + : 2; + final key = + 'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}'; + final cached = _fmtCache.get(key); + if (cached != null) return cached; + + final result = + await formatAmountWithLocale(price, subscription.currency, locale); + _fmtCache.set(key, result, size: result.length); + return result; } /// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지 @@ -178,13 +214,13 @@ class CurrencyUtil { for (var subscription in subscriptions) { if (subscription.isCurrentlyInEvent) { final savings = subscription.eventSavings; - + final converted = await _exchangeRateService.convertBetweenCurrencies( savings, subscription.currency, defaultCurrency, ); - + total += converted ?? savings; } } @@ -204,7 +240,7 @@ class CurrencyUtil { if (!subscription.isCurrentlyInEvent) { return ''; } - + final savings = subscription.eventSavings; return formatAmountWithLocale(savings, subscription.currency, locale); } @@ -225,4 +261,4 @@ class CurrencyUtil { static Future formatAmount(double amount, String currency) async { return formatAmountWithCurrencyAndLocale(amount, currency, 'ko'); } -} \ No newline at end of file +} diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index 574da99..9f3a8e6 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -1,6 +1,8 @@ import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:intl/intl.dart'; +import '../utils/logger.dart'; +import 'cache_manager.dart'; /// 환율 정보 서비스 클래스 class ExchangeRateService { @@ -15,18 +17,34 @@ class ExchangeRateService { // 내부 생성자 ExchangeRateService._internal(); + // 포맷된 환율 문자열 캐시 (언어별) + static final SimpleCacheManager _fmtCache = + SimpleCacheManager( + maxEntries: 64, + maxBytes: 64 * 1024, + ttl: const Duration(minutes: 30), + ); + // 캐싱된 환율 정보 double? _usdToKrwRate; double? _usdToJpyRate; double? _usdToCnyRate; DateTime? _lastUpdated; - // API 요청 URL (ExchangeRate-API 사용) - final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD'; + // API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능 + static const String _defaultApiUrl = + 'https://api.exchangerate-api.com/v4/latest/USD'; + final String _apiUrl = const String.fromEnvironment( + 'EXCHANGE_RATE_API_URL', + defaultValue: _defaultApiUrl, + ); // 기본 환율 상수 + // ignore: constant_identifier_names static const double DEFAULT_USD_TO_KRW_RATE = 1350.0; + // ignore: constant_identifier_names static const double DEFAULT_USD_TO_JPY_RATE = 150.0; + // ignore: constant_identifier_names static const double DEFAULT_USD_TO_CNY_RATE = 7.2; // 캐싱된 환율 반환 (동기적) @@ -44,18 +62,28 @@ class ExchangeRateService { } try { - // API 요청 + // API 요청 (네트워크 불가 환경에서는 예외 발생 가능) final response = await http.get(Uri.parse(_apiUrl)); if (response.statusCode == 200) { final data = json.decode(response.body); - _usdToKrwRate = data['rates']['KRW']?.toDouble(); - _usdToJpyRate = data['rates']['JPY']?.toDouble(); - _usdToCnyRate = data['rates']['CNY']?.toDouble(); + _usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble(); + _usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble(); + _usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble(); _lastUpdated = DateTime.now(); + // 환율 갱신 시 포맷 캐시 무효화 + _fmtCache.clear(); + Log.d( + '환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate'); + return; + } else { + Log.w( + '환율 API 응답 코드: ${response.statusCode} (${response.reasonPhrase})'); } - } catch (e) { - // 오류 발생 시 기본값 사용 + } catch (e, st) { + // 네트워크 실패 시 캐시/기본값 폴백 + Log.w('환율 API 요청 실패. 캐시/기본값 사용'); + Log.e('환율 API 에러', e, st); } } @@ -75,9 +103,10 @@ class ExchangeRateService { } /// USD 금액을 지정된 통화로 변환합니다. - Future convertUsdToTarget(double usdAmount, String targetCurrency) async { + Future convertUsdToTarget( + double usdAmount, String targetCurrency) async { await _fetchAllRatesIfNeeded(); - + switch (targetCurrency) { case 'KRW': final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE; @@ -96,9 +125,10 @@ class ExchangeRateService { } /// 지정된 통화를 USD로 변환합니다. - Future convertTargetToUsd(double amount, String sourceCurrency) async { + Future convertTargetToUsd( + double amount, String sourceCurrency) async { await _fetchAllRatesIfNeeded(); - + switch (sourceCurrency) { case 'KRW': final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE; @@ -118,25 +148,22 @@ class ExchangeRateService { /// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환) Future convertBetweenCurrencies( - double amount, - String fromCurrency, - String toCurrency - ) async { + double amount, String fromCurrency, String toCurrency) async { if (fromCurrency == toCurrency) { return amount; } - + // fromCurrency → USD → toCurrency double? usdAmount; - + if (fromCurrency == 'USD') { usdAmount = amount; } else { usdAmount = await convertTargetToUsd(amount, fromCurrency); } - + if (usdAmount == null) return null; - + if (toCurrency == 'USD') { return usdAmount; } else { @@ -161,32 +188,45 @@ class ExchangeRateService { /// 언어별 환율 정보를 포맷팅하여 반환합니다. Future getFormattedExchangeRateInfoForLocale(String locale) async { await _fetchAllRatesIfNeeded(); - + // 캐시 키 (locale 기준) + final key = 'fx:fmt:$locale'; + final cached = _fmtCache.get(key); + if (cached != null) return cached; + + String result = ''; switch (locale) { case 'ko': final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE; - return NumberFormat.currency( + result = NumberFormat.currency( locale: 'ko_KR', symbol: '₩', decimalDigits: 0, ).format(rate); + break; case 'ja': final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE; - return NumberFormat.currency( + result = NumberFormat.currency( locale: 'ja_JP', symbol: '¥', decimalDigits: 0, ).format(rate); + break; case 'zh': final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE; - return NumberFormat.currency( + result = NumberFormat.currency( locale: 'zh_CN', symbol: '¥', decimalDigits: 2, ).format(rate); + break; default: - return ''; + result = ''; + break; } + + // 대략적인 사이즈(문자 길이)로 캐시 저장 + _fmtCache.set(key, result, size: result.length); + return result; } /// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다. @@ -204,12 +244,13 @@ class ExchangeRateService { } /// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다. - Future getFormattedAmountForLocale(double usdAmount, String locale) async { + Future getFormattedAmountForLocale( + double usdAmount, String locale) async { String targetCurrency; String localeCode; String symbol; int decimalDigits; - + switch (locale) { case 'ko': targetCurrency = 'KRW'; @@ -232,7 +273,7 @@ class ExchangeRateService { default: return '\$$usdAmount'; } - + final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency); if (convertedAmount != null) { final formattedAmount = NumberFormat.currency( diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ab8abb2..d4be196 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,7 +1,6 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz; -import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'dart:io' show Platform; import '../models/subscription_model.dart'; @@ -10,7 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class NotificationService { static final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); - static final _secureStorage = const FlutterSecureStorage(); + static const _secureStorage = FlutterSecureStorage(); static const _notificationEnabledKey = 'notification_enabled'; static const _paymentNotificationEnabledKey = 'payment_notification_enabled'; @@ -150,17 +149,16 @@ class NotificationService { } } - static Future requestPermission() async { // 웹 플랫폼인 경우 false 반환 if (_isWeb) return false; - + // iOS 처리 if (Platform.isIOS) { - final iosImplementation = _notifications - .resolvePlatformSpecificImplementation< + final iosImplementation = + _notifications.resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); - + if (iosImplementation != null) { final granted = await iosImplementation.requestPermissions( alert: true, @@ -170,20 +168,20 @@ class NotificationService { return granted ?? false; } } - + // Android 처리 if (Platform.isAndroid) { - final androidImplementation = _notifications - .resolvePlatformSpecificImplementation< + final androidImplementation = + _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); - + if (androidImplementation != null) { - final granted = await androidImplementation - .requestNotificationsPermission(); + final granted = + await androidImplementation.requestNotificationsPermission(); return granted ?? false; } } - + return false; } @@ -191,32 +189,32 @@ class NotificationService { static Future checkPermission() async { // 웹 플랫폼인 경우 false 반환 if (_isWeb) return false; - + // Android 처리 if (Platform.isAndroid) { - final androidImplementation = _notifications - .resolvePlatformSpecificImplementation< + final androidImplementation = + _notifications.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); - + if (androidImplementation != null) { // Android 13 이상에서만 권한 확인 필요 final isEnabled = await androidImplementation.areNotificationsEnabled(); return isEnabled ?? false; } } - + // iOS 처리 if (Platform.isIOS) { - final iosImplementation = _notifications - .resolvePlatformSpecificImplementation< + final iosImplementation = + _notifications.resolvePlatformSpecificImplementation< IOSFlutterLocalNotificationsPlugin>(); - + if (iosImplementation != null) { final settings = await iosImplementation.checkPermissions(); return settings?.isEnabled ?? false; } } - + return true; // 기본값 } @@ -232,7 +230,7 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { const androidDetails = AndroidNotificationDetails( 'subscription_channel', @@ -242,8 +240,8 @@ class NotificationService { priority: Priority.high, ); - final iosDetails = const DarwinNotificationDetails(); - + const iosDetails = DarwinNotificationDetails(); + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -267,10 +265,10 @@ class NotificationService { title, body, tz.TZDateTime.from(scheduledDate, location), - NotificationDetails(android: androidDetails, iOS: iosDetails), - androidAllowWhileIdle: true, + const NotificationDetails(android: androidDetails, iOS: iosDetails), uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('알림 예약 중 오류 발생: $e'); @@ -305,7 +303,7 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { final notificationId = subscription.id.hashCode; @@ -327,7 +325,7 @@ class NotificationService { android: androidDetails, iOS: iosDetails, ); - + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -352,9 +350,9 @@ class NotificationService { '${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.', tz.TZDateTime.from(subscription.nextBillingDate, location), notificationDetails, - androidAllowWhileIdle: true, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('구독 알림 예약 중 오류 발생: $e'); @@ -380,11 +378,11 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { final paymentDate = subscription.nextBillingDate; final reminderDate = paymentDate.subtract(const Duration(days: 3)); - + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -417,9 +415,9 @@ class NotificationService { priority: Priority.high, ), ), - androidAllowWhileIdle: true, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('결제 알림 예약 중 오류 발생: $e'); @@ -433,11 +431,11 @@ class NotificationService { debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.'); return; } - + try { final expirationDate = subscription.nextBillingDate; final reminderDate = expirationDate.subtract(const Duration(days: 7)); - + // tz.local 초기화 확인 및 재시도 tz.Location location; try { @@ -457,7 +455,7 @@ class NotificationService { } await _notifications.zonedSchedule( - (subscription.id + '_expiration').hashCode, + ('${subscription.id}_expiration').hashCode, '구독 만료 예정 알림', '${subscription.serviceName} 구독이 7일 후 만료됩니다.', tz.TZDateTime.from(reminderDate, location), @@ -470,9 +468,9 @@ class NotificationService { priority: Priority.high, ), ), - androidAllowWhileIdle: true, uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); } catch (e) { debugPrint('만료 알림 예약 중 오류 발생: $e'); @@ -510,16 +508,17 @@ class NotificationService { location = tz.UTC; } } - + // 기본 알림 예약 (지정된 일수 전) - final scheduledDate = - subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith( - hour: reminderHour, - minute: reminderMinute, - second: 0, - millisecond: 0, - microsecond: 0, - ); + final scheduledDate = subscription.nextBillingDate + .subtract(Duration(days: reminderDays)) + .copyWith( + hour: reminderHour, + minute: reminderMinute, + second: 0, + millisecond: 0, + microsecond: 0, + ); // 남은 일수에 따른 메시지 생성 String daysText = '$reminderDays일 후'; @@ -529,19 +528,21 @@ class NotificationService { // 이벤트 종료로 인한 가격 변동 확인 String notificationBody; - if (subscription.isEventActive && + if (subscription.isEventActive && subscription.eventEndDate != null && subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && subscription.eventEndDate!.isAfter(DateTime.now())) { // 이벤트가 결제일 전에 종료되는 경우 final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final normalPrice = subscription.monthlyCost; - notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' - '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; + notificationBody = + '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' + '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; } else { // 일반 알림 final currentPrice = subscription.currentPrice; - notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; + notificationBody = + '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; } await _notifications.zonedSchedule( @@ -568,13 +569,14 @@ class NotificationService { if (isDailyReminder && reminderDays >= 2) { // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 for (int i = reminderDays - 1; i >= 1; i--) { - final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( - hour: reminderHour, - minute: reminderMinute, - second: 0, - millisecond: 0, - microsecond: 0, - ); + final dailyDate = + subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( + hour: reminderHour, + minute: reminderMinute, + second: 0, + millisecond: 0, + microsecond: 0, + ); // 남은 일수에 따른 메시지 생성 String remainingDaysText = '$i일 후'; @@ -584,17 +586,21 @@ class NotificationService { // 각 날짜에 대한 이벤트 종료 확인 String dailyNotificationBody; - if (subscription.isEventActive && + if (subscription.isEventActive && subscription.eventEndDate != null && - subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && + subscription.eventEndDate! + .isBefore(subscription.nextBillingDate) && subscription.eventEndDate!.isAfter(DateTime.now())) { - final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; + final eventPrice = + subscription.eventPrice ?? subscription.monthlyCost; final normalPrice = subscription.monthlyCost; - dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' - '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; + dailyNotificationBody = + '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' + '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; } else { final currentPrice = subscription.currentPrice; - dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; + dailyNotificationBody = + '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; } await _notifications.zonedSchedule( diff --git a/lib/services/sms_scan/subscription_converter.dart b/lib/services/sms_scan/subscription_converter.dart index bd61b73..153045a 100644 --- a/lib/services/sms_scan/subscription_converter.dart +++ b/lib/services/sms_scan/subscription_converter.dart @@ -3,16 +3,21 @@ import '../../models/subscription_model.dart'; class SubscriptionConverter { // SubscriptionModel 리스트를 Subscription 리스트로 변환 - List convertModelsToSubscriptions(List models) { + List convertModelsToSubscriptions( + List models) { final result = []; for (var model in models) { try { final subscription = _convertSingle(model); result.add(subscription); - - print('모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); + + // 개발 편의를 위한 디버그 로그 + // ignore: avoid_print + print( + '모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}'); } catch (e) { + // ignore: avoid_print print('모델 변환 중 오류 발생: $e'); } } @@ -76,4 +81,4 @@ class SubscriptionConverter { return 'monthly'; // 기본값 } } -} \ No newline at end of file +} diff --git a/lib/services/sms_scan/subscription_filter.dart b/lib/services/sms_scan/subscription_filter.dart index 627f692..25f311f 100644 --- a/lib/services/sms_scan/subscription_filter.dart +++ b/lib/services/sms_scan/subscription_filter.dart @@ -1,11 +1,13 @@ import '../../models/subscription.dart'; import '../../models/subscription_model.dart'; +import '../../utils/logger.dart'; class SubscriptionFilter { // 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주) List filterDuplicates( List scanned, List existing) { - print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개'); + Log.d( + '_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개'); // 중복되지 않은 구독만 필터링 return scanned.where((scannedSub) { @@ -16,7 +18,8 @@ class SubscriptionFilter { final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; if (isSameName && isSameCost) { - print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); + Log.d( + '중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); return true; } return false; @@ -27,7 +30,8 @@ class SubscriptionFilter { } // 반복 횟수 기반 필터링 - List filterByRepeatCount(List subscriptions, int minCount) { + List filterByRepeatCount( + List subscriptions, int minCount) { return subscriptions.where((sub) => sub.repeatCount >= minCount).toList(); } @@ -44,7 +48,8 @@ class SubscriptionFilter { List filterByPriceRange( List subscriptions, double minPrice, double maxPrice) { return subscriptions - .where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice) + .where( + (sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice) .toList(); } @@ -52,9 +57,9 @@ class SubscriptionFilter { List filterByCategories( List subscriptions, List categoryIds) { if (categoryIds.isEmpty) return subscriptions; - + return subscriptions.where((sub) { return sub.category != null && categoryIds.contains(sub.category); }).toList(); } -} \ No newline at end of file +} diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index e2b4db6..1af7413 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -1,6 +1,7 @@ -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, compute; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import '../models/subscription_model.dart'; +import '../utils/logger.dart'; import '../temp/test_sms_data.dart'; import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; @@ -11,26 +12,26 @@ class SmsScanner { Future> scanForSubscriptions() async { try { List smsList; - print('SmsScanner: 스캔 시작'); + Log.d('SmsScanner: 스캔 시작'); // 플랫폼별 분기 처리 if (kIsWeb) { // 웹 환경: 테스트 데이터 사용 - print('SmsScanner: 웹 환경에서 테스트 데이터 사용'); + Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용'); smsList = TestSmsData.getTestData(); - print('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); + Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); } else if (PlatformHelper.isIOS) { // iOS: SMS 접근 불가, 빈 리스트 반환 - print('SmsScanner: iOS에서는 SMS 스캔 불가'); + Log.w('SmsScanner: iOS에서는 SMS 스캔 불가'); return []; } else if (PlatformHelper.isAndroid) { // Android: flutter_sms_inbox 사용 - print('SmsScanner: Android에서 실제 SMS 스캔'); + Log.i('SmsScanner: Android에서 실제 SMS 스캔'); smsList = await _scanAndroidSms(); - print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); + Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); } else { // 기타 플랫폼 - print('SmsScanner: 지원하지 않는 플랫폼'); + Log.w('SmsScanner: 지원하지 않는 플랫폼'); return []; } @@ -47,32 +48,32 @@ class SmsScanner { serviceGroups[serviceName]!.add(sms); } - print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); + Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); // 그룹화된 데이터로 구독 분석 for (final entry in serviceGroups.entries) { - print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); + Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); // 2회 이상 반복된 서비스만 구독으로 간주 if (entry.value.length >= 2) { final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 final subscription = _parseSms(serviceSms, entry.value.length); if (subscription != null) { - print( + Log.i( 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); subscriptions.add(subscription); } else { - print('SmsScanner: 구독 파싱 실패: ${entry.key}'); + Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}'); } } else { - print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); + Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); } } - print('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); + Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); return subscriptions; } catch (e) { - print('SmsScanner: 예외 발생: $e'); + Log.e('SmsScanner: 예외 발생', e); throw Exception('SMS 스캔 중 오류 발생: $e'); } } @@ -81,167 +82,36 @@ class SmsScanner { Future> _scanAndroidSms() async { try { final messages = await _query.getAllSms; - final smsList = >[]; - - // SMS 메시지를 분석하여 구독 서비스 감지 + + // Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가) + final serialized = >[]; for (final message in messages) { - final parsedData = _parseRawSms(message); - if (parsedData != null) { - smsList.add(parsedData); - } + serialized.add({ + 'body': message.body ?? '', + 'address': message.address ?? '', + 'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch, + }); } - + + // 대량 파싱은 별도 Isolate로 오프로딩 + final List> smsList = + await compute(_parseRawSmsBatch, serialized); + return smsList; } catch (e) { - print('SmsScanner: Android SMS 스캔 실패: $e'); + Log.e('SmsScanner: Android SMS 스캔 실패', e); return []; } } - // 실제 SMS 메시지를 파싱하여 구독 정보 추출 - Map? _parseRawSms(SmsMessage message) { - try { - final body = message.body ?? ''; - final sender = message.address ?? ''; - final date = message.date ?? DateTime.now(); - - // 구독 서비스 키워드 매칭 - final subscriptionKeywords = [ - '구독', '결제', '정기결제', '자동결제', '월정액', - 'subscription', 'payment', 'billing', 'charge', - '넷플릭스', 'Netflix', '유튜브', 'YouTube', 'Spotify', - '멜론', '웨이브', 'Disney+', '디즈니플러스', 'Apple', - 'Microsoft', 'GitHub', 'Adobe', 'Amazon' - ]; - - // 구독 관련 키워드가 있는지 확인 - bool isSubscription = subscriptionKeywords.any((keyword) => - body.toLowerCase().contains(keyword.toLowerCase()) || - sender.toLowerCase().contains(keyword.toLowerCase()) - ); - - if (!isSubscription) { - return null; - } - - // 서비스명 추출 - String serviceName = _extractServiceName(body, sender); - - // 금액 추출 - double? amount = _extractAmount(body); - - // 결제 주기 추출 - String billingCycle = _extractBillingCycle(body); - - return { - 'serviceName': serviceName, - 'monthlyCost': amount ?? 0.0, - 'billingCycle': billingCycle, - 'message': body, - 'nextBillingDate': _calculateNextBillingFromDate(date, billingCycle).toIso8601String(), - 'previousPaymentDate': date.toIso8601String(), - }; - } catch (e) { - print('SmsScanner: SMS 파싱 실패: $e'); - return null; - } - } - - // 서비스명 추출 로직 - String _extractServiceName(String body, String sender) { - // 알려진 서비스 매핑 - final servicePatterns = { - 'netflix': '넷플릭스', - 'youtube': '유튜브 프리미엄', - 'spotify': 'Spotify', - 'disney': '디즈니플러스', - 'apple': 'Apple', - 'microsoft': 'Microsoft', - 'github': 'GitHub', - 'adobe': 'Adobe', - '멜론': '멜론', - '웨이브': '웨이브', - }; - - // 메시지나 발신자에서 서비스명 찾기 - final combinedText = '$body $sender'.toLowerCase(); - - for (final entry in servicePatterns.entries) { - if (combinedText.contains(entry.key)) { - return entry.value; - } - } - - // 찾지 못한 경우 - return _extractServiceNameFromSender(sender); - } - - // 발신자 정보에서 서비스명 추출 - String _extractServiceNameFromSender(String sender) { - // 숫자만 있으면 제거 - if (RegExp(r'^\d+$').hasMatch(sender)) { - return '알 수 없는 서비스'; - } - - // 특수문자 제거하고 서비스명으로 사용 - return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); - } - - // 금액 추출 로직 - double? _extractAmount(String body) { - // 다양한 금액 패턴 매칭 - final patterns = [ - RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 - RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 - RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD - RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 - ]; - - for (final pattern in patterns) { - final match = pattern.firstMatch(body); - if (match != null) { - String amountStr = match.group(1) ?? ''; - amountStr = amountStr.replaceAll(',', ''); - return double.tryParse(amountStr); - } - } - - return null; - } - - // 결제 주기 추출 로직 - String _extractBillingCycle(String body) { - if (body.contains('월') || body.contains('monthly') || body.contains('매월')) { - return 'monthly'; - } else if (body.contains('년') || body.contains('yearly') || body.contains('annual')) { - return 'yearly'; - } else if (body.contains('주') || body.contains('weekly')) { - return 'weekly'; - } - - // 기본값 - return 'monthly'; - } - - // 다음 결제일 계산 - DateTime _calculateNextBillingFromDate(DateTime lastDate, String billingCycle) { - switch (billingCycle) { - case 'monthly': - return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); - case 'yearly': - return DateTime(lastDate.year + 1, lastDate.month, lastDate.day); - case 'weekly': - return lastDate.add(const Duration(days: 7)); - default: - return lastDate.add(const Duration(days: 30)); - } - } + // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체 SubscriptionModel? _parseSms(Map sms, int repeatCount) { try { final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; - final billingCycle = SubscriptionModel.normalizeBillingCycle(sms['billingCycle'] as String? ?? 'monthly'); + final billingCycle = SubscriptionModel.normalizeBillingCycle( + sms['billingCycle'] as String? ?? 'monthly'); final nextBillingDateStr = sms['nextBillingDate'] as String?; // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; @@ -259,7 +129,7 @@ class SmsScanner { 'Spotify Premium' ]; if (dollarServices.any((service) => serviceName.contains(service))) { - print('서비스명 $serviceName으로 USD 통화 단위 확정'); + Log.d('서비스명 $serviceName으로 USD 통화 단위 확정'); currency = 'USD'; } @@ -369,8 +239,6 @@ class SmsScanner { return serviceUrls[serviceName]; } - - // 메시지에서 통화 단위를 감지하는 함수 String _detectCurrency(String message) { final dollarKeywords = [ @@ -391,7 +259,7 @@ class SmsScanner { // 서비스명 기반 통화 단위 확인 for (final service in serviceCurrencyMap.keys) { if (message.contains(service)) { - print('_detectCurrency: ${service}는 USD 서비스로 판별됨'); + Log.d('_detectCurrency: $service는 USD 서비스로 판별됨'); return 'USD'; } } @@ -399,7 +267,7 @@ class SmsScanner { // 메시지에 달러 관련 키워드가 있는지 확인 for (final keyword in dollarKeywords) { if (message.toLowerCase().contains(keyword.toLowerCase())) { - print('_detectCurrency: USD 키워드 발견: $keyword'); + Log.d('_detectCurrency: USD 키워드 발견: $keyword'); return 'USD'; } } @@ -407,4 +275,149 @@ class SmsScanner { // 기본값은 원화 return 'KRW'; } -} \ No newline at end of file +} + +// ===== Isolate 오프로딩용 Top-level 파서 ===== + +// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환 +List> _parseRawSmsBatch( + List> messages) { + // 키워드/정규식은 Isolate 내에서 재생성 (간단 복제) + const subscriptionKeywords = [ + '구독', + '결제', + '정기결제', + '자동결제', + '월정액', + 'subscription', + 'payment', + 'billing', + 'charge', + '넷플릭스', + 'Netflix', + '유튜브', + 'YouTube', + 'Spotify', + '멜론', + '웨이브', + 'Disney+', + '디즈니플러스', + 'Apple', + 'Microsoft', + 'GitHub', + 'Adobe', + 'Amazon' + ]; + + final amountPatterns = [ + RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 + RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 + RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD + RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 + ]; + + final results = >[]; + + for (final m in messages) { + final body = (m['body'] as String?) ?? ''; + final sender = (m['address'] as String?) ?? ''; + final dateMillis = + (m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch; + final date = DateTime.fromMillisecondsSinceEpoch(dateMillis); + + final lowerBody = body.toLowerCase(); + final lowerSender = sender.toLowerCase(); + final isSubscription = subscriptionKeywords.any((k) => + lowerBody.contains(k.toLowerCase()) || + lowerSender.contains(k.toLowerCase())); + + if (!isSubscription) continue; + + final serviceName = _isoExtractServiceName(body, sender); + final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0; + final billingCycle = _isoExtractBillingCycle(body); + final nextBillingDate = + _isoCalculateNextBillingFromDate(date, billingCycle); + + results.add({ + 'serviceName': serviceName, + 'monthlyCost': amount, + 'billingCycle': billingCycle, + 'message': body, + 'nextBillingDate': nextBillingDate.toIso8601String(), + 'previousPaymentDate': date.toIso8601String(), + }); + } + + return results; +} + +String _isoExtractServiceName(String body, String sender) { + final servicePatterns = { + 'netflix': '넷플릭스', + 'youtube': '유튜브 프리미엄', + 'spotify': 'Spotify', + 'disney': '디즈니플러스', + 'apple': 'Apple', + 'microsoft': 'Microsoft', + 'github': 'GitHub', + 'adobe': 'Adobe', + '멜론': '멜론', + '웨이브': '웨이브', + }; + + final combined = '$body $sender'.toLowerCase(); + for (final e in servicePatterns.entries) { + if (combined.contains(e.key)) return e.value; + } + return _isoExtractServiceNameFromSender(sender); +} + +String _isoExtractServiceNameFromSender(String sender) { + if (RegExp(r'^\d+$').hasMatch(sender)) { + return '알 수 없는 서비스'; + } + return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); +} + +double? _isoExtractAmount(String body, List patterns) { + for (final pattern in patterns) { + final match = pattern.firstMatch(body); + if (match != null) { + var amountStr = match.group(1) ?? ''; + amountStr = amountStr.replaceAll(',', ''); + final parsed = double.tryParse(amountStr); + if (parsed != null) return parsed; + } + } + return null; +} + +String _isoExtractBillingCycle(String body) { + if (body.contains('월') || + body.toLowerCase().contains('monthly') || + body.contains('매월')) { + return 'monthly'; + } else if (body.contains('년') || + body.toLowerCase().contains('yearly') || + body.toLowerCase().contains('annual')) { + return 'yearly'; + } else if (body.contains('주') || body.toLowerCase().contains('weekly')) { + return 'weekly'; + } + return 'monthly'; +} + +DateTime _isoCalculateNextBillingFromDate( + DateTime lastDate, String billingCycle) { + switch (billingCycle) { + case 'monthly': + return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); + case 'yearly': + return DateTime(lastDate.year + 1, lastDate.month, lastDate.day); + case 'weekly': + return lastDate.add(const Duration(days: 7)); + default: + return lastDate.add(const Duration(days: 30)); + } +} diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart index e1c4f63..7922783 100644 --- a/lib/services/sms_service.dart +++ b/lib/services/sms_service.dart @@ -9,26 +9,26 @@ class SMSService { static Future requestSMSPermission() async { // 웹이나 iOS에서는 SMS 권한 불필요 if (kIsWeb || PlatformHelper.isIOS) return true; - + // Android에서만 권한 요청 if (PlatformHelper.isAndroid) { final status = await permission.Permission.sms.request(); return status.isGranted; } - + return false; } static Future hasSMSPermission() async { // 웹이나 iOS에서는 항상 true 반환 (권한 불필요) if (kIsWeb || PlatformHelper.isIOS) return true; - + // Android에서만 실제 권한 확인 if (PlatformHelper.isAndroid) { final status = await permission.Permission.sms.status; return status.isGranted; } - + return false; } diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index 9f5d854..61410b9 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -17,39 +17,40 @@ class SubscriptionUrlMatcher { static CancellationUrlService? _cancellationService; static ServiceNameResolver? _nameResolver; static SmsExtractorService? _smsExtractor; - + /// 서비스 초기화 static Future initialize() async { if (_dataRepository != null && _dataRepository!.isInitialized) return; - + // 1. 데이터 저장소 초기화 _dataRepository = ServiceDataRepository(); await _dataRepository!.initialize(); - + // 2. 서비스 초기화 _categoryMapper = CategoryMapperService(_dataRepository!); _urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!); - _cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!); + _cancellationService = + CancellationUrlService(_dataRepository!, _urlMatcher!); _nameResolver = ServiceNameResolver(_dataRepository!); _smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!); } - + /// 도메인 추출 (www와 TLD 제외) static String? extractDomain(String url) { return _urlMatcher?.extractDomain(url); } - + /// URL로 서비스 찾기 static Future findServiceByUrl(String url) async { await initialize(); return _urlMatcher?.findServiceByUrl(url); } - + /// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지) static String? suggestUrl(String serviceName) { return _urlMatcher?.suggestUrl(serviceName); } - + /// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기 static Future findCancellationUrl({ String? serviceName, @@ -63,19 +64,20 @@ class SubscriptionUrlMatcher { locale: locale, ); } - + /// 서비스에 공식 해지 안내 페이지가 있는지 확인 static Future hasCancellationPage(String serviceNameOrUrl) async { await initialize(); - return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false; + return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? + false; } - + /// 서비스명으로 카테고리 찾기 static Future findCategoryByServiceName(String serviceName) async { await initialize(); return _categoryMapper?.findCategoryByServiceName(serviceName); } - + /// 현재 로케일에 따라 서비스 표시명 가져오기 static Future getServiceDisplayName({ required String serviceName, @@ -83,17 +85,18 @@ class SubscriptionUrlMatcher { }) async { await initialize(); return await _nameResolver?.getServiceDisplayName( - serviceName: serviceName, - locale: locale, - ) ?? serviceName; + serviceName: serviceName, + locale: locale, + ) ?? + serviceName; } - + /// SMS에서 URL과 서비스 정보 추출 static Future extractServiceFromSms(String smsText) async { await initialize(); return _smsExtractor?.extractServiceFromSms(smsText); } - + /// URL이 알려진 서비스 URL인지 확인 static Future isKnownServiceUrl(String url) async { await initialize(); @@ -104,4 +107,4 @@ class SubscriptionUrlMatcher { static String? findMatchingUrl(String text, {bool usePartialMatch = true}) { return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch); } -} \ No newline at end of file +} 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/services/url_matcher/data/legacy_service_data.dart b/lib/services/url_matcher/data/legacy_service_data.dart index 0e09225..19b0675 100644 --- a/lib/services/url_matcher/data/legacy_service_data.dart +++ b/lib/services/url_matcher/data/legacy_service_data.dart @@ -336,22 +336,22 @@ class LegacyServiceData { // 모든 서비스 매핑을 합친 맵 static Map get allServices => { - ...ottServices, - ...musicServices, - ...storageServices, - ...aiServices, - ...programmingServices, - ...officeTools, - ...lifestyleServices, - ...shoppingServices, - ...telecomServices, - ...otherServices, - }; + ...ottServices, + ...musicServices, + ...storageServices, + ...aiServices, + ...programmingServices, + ...officeTools, + ...lifestyleServices, + ...shoppingServices, + ...telecomServices, + ...otherServices, + }; /// 서비스 카테고리 찾기 static String? getCategoryForService(String serviceName) { final lowerName = serviceName.toLowerCase(); - + if (ottServices.containsKey(lowerName)) return 'ott'; if (musicServices.containsKey(lowerName)) return 'music'; if (storageServices.containsKey(lowerName)) return 'storage'; @@ -362,7 +362,7 @@ class LegacyServiceData { if (shoppingServices.containsKey(lowerName)) return 'shopping'; if (telecomServices.containsKey(lowerName)) return 'telecom'; if (otherServices.containsKey(lowerName)) return 'other'; - + return null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/data/service_data_repository.dart b/lib/services/url_matcher/data/service_data_repository.dart index 018f66c..43a51e2 100644 --- a/lib/services/url_matcher/data/service_data_repository.dart +++ b/lib/services/url_matcher/data/service_data_repository.dart @@ -1,30 +1,32 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +import '../../../utils/logger.dart'; /// 서비스 데이터를 관리하는 저장소 클래스 class ServiceDataRepository { Map? _servicesData; bool _isInitialized = false; - + /// JSON 데이터 초기화 Future initialize() async { if (_isInitialized) return; - + try { - final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); + final jsonString = + await rootBundle.loadString('assets/data/subscription_services.json'); _servicesData = json.decode(jsonString); _isInitialized = true; - print('ServiceDataRepository: JSON 데이터 로드 완료'); + Log.i('ServiceDataRepository: JSON 데이터 로드 완료'); } catch (e) { - print('ServiceDataRepository: JSON 로드 실패 - $e'); + Log.w('ServiceDataRepository: JSON 로드 실패 - $e'); // 로드 실패시 기존 하드코딩 데이터 사용 _isInitialized = true; } } - + /// 서비스 데이터 가져오기 Map? getServicesData() => _servicesData; - + /// 초기화 여부 확인 bool get isInitialized => _isInitialized; -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/models/service_info.dart b/lib/services/url_matcher/models/service_info.dart index c371af8..0f8ae21 100644 --- a/lib/services/url_matcher/models/service_info.dart +++ b/lib/services/url_matcher/models/service_info.dart @@ -7,7 +7,7 @@ class ServiceInfo { final String categoryId; final String categoryNameKr; final String categoryNameEn; - + ServiceInfo({ required this.serviceId, required this.serviceName, @@ -17,4 +17,4 @@ class ServiceInfo { required this.categoryNameKr, required this.categoryNameEn, }); -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/cancellation_url_service.dart b/lib/services/url_matcher/services/cancellation_url_service.dart index 74542ac..51e91df 100644 --- a/lib/services/url_matcher/services/cancellation_url_service.dart +++ b/lib/services/url_matcher/services/cancellation_url_service.dart @@ -6,9 +6,9 @@ import 'url_matcher_service.dart'; class CancellationUrlService { final ServiceDataRepository _dataRepository; final UrlMatcherService _urlMatcher; - + CancellationUrlService(this._dataRepository, this._urlMatcher); - + /// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기 Future findCancellationUrl({ String? serviceName, @@ -19,47 +19,55 @@ class CancellationUrlService { final servicesData = _dataRepository.getServicesData(); 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; - + final services = (categoryData as Map)['services'] + as Map; + for (final serviceData in services.values) { - final names = List.from((serviceData as Map)['names'] ?? []); - + 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 (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']; + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; } } } } } } - + // 2. URL로 찾기 if (websiteUrl != null && websiteUrl.isNotEmpty) { final domain = _urlMatcher.extractDomain(websiteUrl); if (domain != null) { for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - + final services = (categoryData as Map)['services'] + as Map; + for (final serviceData in services.values) { - final domains = List.from((serviceData as Map)['domains'] ?? []); - + 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 (domain.contains(serviceDomain) || + serviceDomain.contains(domain)) { + final cancellationUrls = + serviceData['cancellationUrls'] as Map?; if (cancellationUrls != null) { - return cancellationUrls[locale] ?? - cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; } } } @@ -68,7 +76,7 @@ class CancellationUrlService { } } } - + // JSON에서 못 찾았으면 레거시 방식으로 찾기 return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); } @@ -126,4 +134,4 @@ class CancellationUrlService { ); return cancellationUrl != null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/category_mapper_service.dart b/lib/services/url_matcher/services/category_mapper_service.dart index 872a093..c407697 100644 --- a/lib/services/url_matcher/services/category_mapper_service.dart +++ b/lib/services/url_matcher/services/category_mapper_service.dart @@ -4,41 +4,43 @@ import '../data/legacy_service_data.dart'; /// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스 class CategoryMapperService { final ServiceDataRepository _dataRepository; - + CategoryMapperService(this._dataRepository); - + /// 서비스명으로 카테고리 찾기 Future findCategoryByServiceName(String serviceName) async { if (serviceName.isEmpty) return null; - + final lowerName = serviceName.toLowerCase().trim(); - + // JSON 데이터가 있으면 JSON에서 찾기 final servicesData = _dataRepository.getServicesData(); 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'] ?? []); - + final names = List.from( + (serviceData as Map)['names'] ?? []); + for (final name in names) { - if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + if (lowerName.contains(name.toLowerCase()) || + name.toLowerCase().contains(lowerName)) { return getCategoryIdByKey(categoryId); } } } } } - + // JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측 return getCategoryForLegacyService(serviceName); } - + /// 카테고리 키를 실제 카테고리 ID로 매핑 String getCategoryIdByKey(String key) { // 여기에 실제 앱의 카테고리 ID 매핑을 추가 @@ -68,21 +70,39 @@ class CategoryMapperService { return 'other'; } } - + /// 레거시 서비스명으로 카테고리 추측 String getCategoryForLegacyService(String serviceName) { final lowerName = serviceName.toLowerCase(); - - if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; - if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; - if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; - if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; - if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; - if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; - if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; - if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; - if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; - + + if (LegacyServiceData.ottServices.containsKey(lowerName)) { + return 'ott_services'; + } + if (LegacyServiceData.musicServices.containsKey(lowerName)) { + return 'music_streaming'; + } + if (LegacyServiceData.storageServices.containsKey(lowerName)) { + return 'cloud_storage'; + } + if (LegacyServiceData.aiServices.containsKey(lowerName)) { + return 'ai_services'; + } + if (LegacyServiceData.programmingServices.containsKey(lowerName)) { + return 'dev_tools'; + } + if (LegacyServiceData.officeTools.containsKey(lowerName)) { + return 'office_tools'; + } + if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) { + return 'lifestyle'; + } + if (LegacyServiceData.shoppingServices.containsKey(lowerName)) { + return 'shopping'; + } + if (LegacyServiceData.telecomServices.containsKey(lowerName)) { + return 'telecom'; + } + return 'other'; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/service_name_resolver.dart b/lib/services/url_matcher/services/service_name_resolver.dart index 76eaa17..e032fb8 100644 --- a/lib/services/url_matcher/services/service_name_resolver.dart +++ b/lib/services/url_matcher/services/service_name_resolver.dart @@ -3,9 +3,9 @@ import '../data/service_data_repository.dart'; /// 서비스명 관련 기능을 제공하는 서비스 클래스 class ServiceNameResolver { final ServiceDataRepository _dataRepository; - + ServiceNameResolver(this._dataRepository); - + /// 현재 로케일에 따라 서비스 표시명 가져오기 Future getServiceDisplayName({ required String serviceName, @@ -15,22 +15,23 @@ class ServiceNameResolver { 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; - + 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()) || + if (lowerName == name.toLowerCase() || + lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { // 로케일에 따라 적절한 이름 반환 if (locale == 'ko' || locale == 'kr') { @@ -40,11 +41,11 @@ class ServiceNameResolver { } } } - + // 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; @@ -54,8 +55,8 @@ class ServiceNameResolver { } } } - + // 찾지 못한 경우 원래 이름 반환 return serviceName; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/sms_extractor_service.dart b/lib/services/url_matcher/services/sms_extractor_service.dart index 4e1f7b5..f07e01d 100644 --- a/lib/services/url_matcher/services/sms_extractor_service.dart +++ b/lib/services/url_matcher/services/sms_extractor_service.dart @@ -7,9 +7,9 @@ import 'category_mapper_service.dart'; class SmsExtractorService { final UrlMatcherService _urlMatcher; final CategoryMapperService _categoryMapper; - + SmsExtractorService(this._urlMatcher, this._categoryMapper); - + /// SMS에서 URL과 서비스 정보 추출 Future extractServiceFromSms(String smsText) async { // URL 패턴 찾기 @@ -17,9 +17,9 @@ class SmsExtractorService { 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) { @@ -29,15 +29,17 @@ class SmsExtractorService { } } } - + // URL로 못 찾았으면 서비스명으로 시도 final lowerSms = smsText.toLowerCase(); - + // 모든 서비스명 검사 for (final entry in LegacyServiceData.allServices.entries) { if (lowerSms.contains(entry.key.toLowerCase())) { - final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other'; - + final categoryId = + await _categoryMapper.findCategoryByServiceName(entry.key) ?? + 'other'; + return ServiceInfo( serviceId: entry.key, serviceName: entry.key, @@ -49,7 +51,7 @@ class SmsExtractorService { ); } } - + return null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/services/url_matcher_service.dart b/lib/services/url_matcher/services/url_matcher_service.dart index 090a83d..8282994 100644 --- a/lib/services/url_matcher/services/url_matcher_service.dart +++ b/lib/services/url_matcher/services/url_matcher_service.dart @@ -2,28 +2,29 @@ import '../models/service_info.dart'; import '../data/service_data_repository.dart'; import '../data/legacy_service_data.dart'; import 'category_mapper_service.dart'; +import '../../../utils/logger.dart'; /// URL 매칭 관련 기능을 제공하는 서비스 클래스 class UrlMatcherService { final ServiceDataRepository _dataRepository; final CategoryMapperService _categoryMapper; - + UrlMatcherService(this._dataRepository, this._categoryMapper); - + /// 도메인 추출 (www와 TLD 제외) 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) { // 서브도메인 포함 전체 도메인 반환 @@ -32,40 +33,41 @@ class UrlMatcherService { // 메인 도메인만 반환 return parts[0]; } - + return null; } catch (e) { - print('UrlMatcherService: 도메인 추출 실패 - $e'); + Log.e('UrlMatcherService: 도메인 추출 실패', e); return null; } } - + /// URL로 서비스 찾기 Future findServiceByUrl(String url) async { final domain = extractDomain(url); if (domain == null) return null; - + // JSON 데이터가 있으면 JSON에서 찾기 final servicesData = _dataRepository.getServicesData(); 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)) { + 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, @@ -80,13 +82,13 @@ class UrlMatcherService { } } } - + // JSON에서 못 찾았으면 레거시 방식으로 찾기 for (final entry in LegacyServiceData.allServices.entries) { final serviceUrl = entry.value; final serviceDomain = extractDomain(serviceUrl); - - if (serviceDomain != null && + + if (serviceDomain != null && (domain.contains(serviceDomain) || serviceDomain.contains(domain))) { return ServiceInfo( serviceId: entry.key, @@ -99,14 +101,14 @@ class UrlMatcherService { ); } } - + return null; } - + /// 서비스명으로 URL 찾기 String? suggestUrl(String serviceName) { if (serviceName.isEmpty) { - print('UrlMatcherService: 빈 serviceName'); + Log.w('UrlMatcherService: 빈 serviceName'); return null; } @@ -117,7 +119,7 @@ class UrlMatcherService { // 정확한 매칭을 먼저 시도 for (final entry in LegacyServiceData.allServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -125,7 +127,7 @@ class UrlMatcherService { // OTT 서비스 검사 for (final entry in LegacyServiceData.ottServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -133,7 +135,7 @@ class UrlMatcherService { // 음악 서비스 검사 for (final entry in LegacyServiceData.musicServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -141,7 +143,7 @@ class UrlMatcherService { // AI 서비스 검사 for (final entry in LegacyServiceData.aiServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -149,7 +151,7 @@ class UrlMatcherService { // 프로그래밍 서비스 검사 for (final entry in LegacyServiceData.programmingServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -157,7 +159,7 @@ class UrlMatcherService { // 오피스 툴 검사 for (final entry in LegacyServiceData.officeTools.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -165,7 +167,7 @@ class UrlMatcherService { // 기타 서비스 검사 for (final entry in LegacyServiceData.otherServices.entries) { if (lowerName.contains(entry.key.toLowerCase())) { - print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } @@ -174,19 +176,19 @@ class UrlMatcherService { for (final entry in LegacyServiceData.allServices.entries) { final key = entry.key.toLowerCase(); if (key.contains(lowerName) || lowerName.contains(key)) { - print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); + Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); return entry.value; } } - print('UrlMatcherService: 매칭 실패 - $lowerName'); + Log.d('UrlMatcherService: 매칭 실패 - $lowerName'); return null; } catch (e) { - print('UrlMatcherService: suggestUrl 에러 - $e'); + Log.e('UrlMatcherService: suggestUrl 에러', e); return null; } } - + /// URL이 알려진 서비스 URL인지 확인 Future isKnownServiceUrl(String url) async { final serviceInfo = await findServiceByUrl(url); @@ -232,4 +234,4 @@ class UrlMatcherService { return null; } -} \ No newline at end of file +} diff --git a/lib/services/url_matcher/url_matcher.dart b/lib/services/url_matcher/url_matcher.dart index d2dbf4e..cc800c8 100644 --- a/lib/services/url_matcher/url_matcher.dart +++ b/lib/services/url_matcher/url_matcher.dart @@ -1,2 +1,2 @@ /// URL Matcher 패키지의 export 파일 -export 'models/service_info.dart'; \ No newline at end of file +export 'models/service_info.dart'; diff --git a/lib/temp/test_sms_data.dart b/lib/temp/test_sms_data.dart index 73a9706..146338e 100644 --- a/lib/temp/test_sms_data.dart +++ b/lib/temp/test_sms_data.dart @@ -210,6 +210,7 @@ class TestSmsData { } } + // ignore: avoid_print print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}개'); return resultData; } @@ -233,7 +234,7 @@ class TestSmsData { ]; // Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제) - final microsoftMonthlyCost = 12800.0 / 12; + const microsoftMonthlyCost = 12800.0 / 12; // 최근 6개월 데이터 생성 for (int i = 0; i < 6; i++) { diff --git a/lib/theme/adaptive_theme.dart b/lib/theme/adaptive_theme.dart index cd24ea3..a705569 100644 --- a/lib/theme/adaptive_theme.dart +++ b/lib/theme/adaptive_theme.dart @@ -7,7 +7,7 @@ import 'app_theme.dart'; class AdaptiveTheme { /// 라이트 테마 static ThemeData get lightTheme => AppTheme.lightTheme; - + /// 다크 테마 static ThemeData get darkTheme { return ThemeData( @@ -19,24 +19,21 @@ class AdaptiveTheme { secondary: AppColors.secondaryColor, tertiary: AppColors.infoColor, error: AppColors.dangerColor, - background: const Color(0xFF121212), - surface: const Color(0xFF1E1E1E), + surface: Color(0xFF1E1E1E), ), - scaffoldBackgroundColor: const Color(0xFF121212), - cardTheme: CardThemeData( color: const Color(0xFF1E1E1E), elevation: 2, shadowColor: Colors.black.withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5), + side: BorderSide( + color: Colors.white.withValues(alpha: 0.1), width: 0.5), ), clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), ), - appBarTheme: AppBarTheme( backgroundColor: const Color(0xFF1E1E1E), foregroundColor: Colors.white, @@ -53,7 +50,6 @@ class AdaptiveTheme { size: 24, ), ), - textTheme: TextTheme( headlineLarge: const TextStyle( color: Colors.white, @@ -119,22 +115,24 @@ class AdaptiveTheme { height: 1.5, ), ), - inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: const Color(0xFF2A2A2A), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1), + borderSide: + BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), + borderSide: + const BorderSide(color: AppColors.primaryColor, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), @@ -151,7 +149,6 @@ class AdaptiveTheme { fontWeight: FontWeight.w400, ), ), - elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryColor, @@ -164,7 +161,6 @@ class AdaptiveTheme { elevation: 0, ), ), - dividerTheme: DividerThemeData( color: Colors.white.withValues(alpha: 0.1), thickness: 1, @@ -172,13 +168,12 @@ class AdaptiveTheme { ), ); } - + /// OLED 최적화 다크 테마 static ThemeData get oledTheme { return darkTheme.copyWith( scaffoldBackgroundColor: Colors.black, colorScheme: darkTheme.colorScheme.copyWith( - background: Colors.black, surface: const Color(0xFF0A0A0A), ), cardTheme: darkTheme.cardTheme.copyWith( @@ -192,7 +187,7 @@ class AdaptiveTheme { ), ); } - + /// 고대비 테마 static ThemeData get highContrastTheme { return ThemeData( @@ -203,10 +198,8 @@ class AdaptiveTheme { secondary: Colors.black87, tertiary: Colors.black54, error: Colors.red, - background: Colors.white, surface: Colors.white, ), - textTheme: const TextTheme( headlineLarge: TextStyle( color: Colors.black, @@ -234,7 +227,6 @@ class AdaptiveTheme { fontWeight: FontWeight.w500, ), ), - cardTheme: CardThemeData( elevation: 0, shape: RoundedRectangleBorder( @@ -242,7 +234,6 @@ class AdaptiveTheme { side: const BorderSide(color: Colors.black, width: 2), ), ), - elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: Colors.black, @@ -255,31 +246,28 @@ class AdaptiveTheme { ), ); } - + /// 시스템 테마에 따른 상태바 스타일 적용 static void applySystemUIOverlay(BuildContext context) { final brightness = Theme.of(context).brightness; final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black; - + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( statusBarColor: Colors.transparent, - statusBarIconBrightness: brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - statusBarBrightness: brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - systemNavigationBarColor: isOled - ? Colors.black - : (brightness == Brightness.dark - ? const Color(0xFF121212) + statusBarIconBrightness: + brightness == Brightness.dark ? Brightness.light : Brightness.dark, + statusBarBrightness: + brightness == Brightness.dark ? Brightness.light : Brightness.dark, + systemNavigationBarColor: isOled + ? Colors.black + : (brightness == Brightness.dark + ? const Color(0xFF121212) : Colors.white), - systemNavigationBarIconBrightness: brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, + systemNavigationBarIconBrightness: + brightness == Brightness.dark ? Brightness.light : Brightness.dark, )); } - + /// 접근성 설정에 따른 테마 조정 static ThemeData getAccessibleTheme( ThemeData baseTheme, { @@ -290,9 +278,9 @@ class AdaptiveTheme { if (highContrast) { return highContrastTheme; } - + ThemeData theme = baseTheme; - + if (largeText) { theme = theme.copyWith( textTheme: theme.textTheme.apply( @@ -300,7 +288,7 @@ class AdaptiveTheme { ), ); } - + if (reduceMotion) { theme = theme.copyWith( pageTransitionsTheme: const PageTransitionsTheme( @@ -311,7 +299,7 @@ class AdaptiveTheme { ), ); } - + return theme; } } @@ -331,7 +319,7 @@ class ThemeSettings { final bool largeText; final bool reduceMotion; final bool highContrast; - + const ThemeSettings({ this.mode = AppThemeMode.system, this.useSystemColors = false, @@ -339,7 +327,7 @@ class ThemeSettings { this.reduceMotion = false, this.highContrast = false, }); - + ThemeSettings copyWith({ AppThemeMode? mode, bool? useSystemColors, @@ -355,15 +343,15 @@ class ThemeSettings { highContrast: highContrast ?? this.highContrast, ); } - + Map toJson() => { - 'mode': mode.name, - 'useSystemColors': useSystemColors, - 'largeText': largeText, - 'reduceMotion': reduceMotion, - 'highContrast': highContrast, - }; - + 'mode': mode.name, + 'useSystemColors': useSystemColors, + 'largeText': largeText, + 'reduceMotion': reduceMotion, + 'highContrast': highContrast, + }; + factory ThemeSettings.fromJson(Map json) { return ThemeSettings( mode: AppThemeMode.values.firstWhere( @@ -376,4 +364,4 @@ class ThemeSettings { highContrast: json['highContrast'] ?? false, ); } -} \ No newline at end of file +} diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart index e738499..0c10bdc 100644 --- a/lib/theme/app_colors.dart +++ b/lib/theme/app_colors.dart @@ -27,14 +27,14 @@ class AppColors { // 보더 & 디바이더 static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200 static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200 - + // 그림자 (color.md 가이드) static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity // 그라데이션 컬러 - 다양한 효과를 위한 조합 static const List blueGradient = [ Color(0xFF2563EB), // 딥 블루 - Color(0xFF60A5FA) // 스카이 블루 + Color(0xFF60A5FA) // 스카이 블루 ]; static const List tealGradient = [ Color(0xFF14B8A6), @@ -59,52 +59,52 @@ class AppColors { static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity) static const glassBorder = Color(0xFF2563EB); // 딥 블루 테두리 static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity) - + // 다크 모드용 Glassmorphism 색상 static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity) static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity) static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity) static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity) - + // 백드롭 블러 효과를 위한 그라디언트 static const List glassGradient = [ Color(0x33FFFFFF), // 20% white Color(0x1AFFFFFF), // 10% white ]; - + static const List glassGradientDark = [ Color(0x1A000000), // 10% black Color(0x0F000000), // 6% black ]; - + // 메인 그라데이션 static const List mainGradient = [ Color(0xFF2563EB), // 딥 블루 Color(0xFF60A5FA), // 스카이 블루 Color(0xFFE0E7EF), // 라이트 그레이 ]; - + static const List accentGradient = [ Color(0xFF38BDF8), // 소프트 민트 Color(0xFF60A5FA), // 스카이 블루 ]; - + // 시간대별 배경 그라디언트 static const List morningGradient = [ Color(0xFFFED7AA), // 따뜻한 오렌지 Color(0xFFFBBF24), // 부드러운 노랑 ]; - + static const List dayGradient = [ Color(0xFFDDEAFC), // 연한 하늘색 Color(0xFFBFDBFE), // 맑은 파랑 ]; - + static const List eveningGradient = [ Color(0xFFFCA5A5), // 부드러운 핑크 Color(0xFFC084FC), // 연한 보라 ]; - + static const List nightGradient = [ Color(0xFF4338CA), // 깊은 인디고 Color(0xFF1E1B4B), // 다크 네이비 diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 20ca69e..df42009 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -10,7 +10,6 @@ class AppTheme { secondary: AppColors.secondaryColor, tertiary: AppColors.infoColor, error: AppColors.dangerColor, - background: AppColors.backgroundColor, surface: AppColors.surfaceColor, ), @@ -36,13 +35,13 @@ class AppTheme { foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, - titleTextStyle: const TextStyle( + titleTextStyle: TextStyle( color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), - iconTheme: const IconThemeData( + iconTheme: IconThemeData( color: AppColors.primaryColor, size: 24, ), @@ -51,22 +50,22 @@ class AppTheme { // 타이포그래피 - Metronic Tailwind 스타일 textTheme: const TextTheme( // 헤드라인 - 페이지 제목 - headlineLarge: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + headlineLarge: TextStyle( + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineMedium: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + headlineMedium: TextStyle( + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineSmall: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + headlineSmall: TextStyle( + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 24, fontWeight: FontWeight.w600, letterSpacing: -0.25, @@ -74,22 +73,22 @@ class AppTheme { ), // 타이틀 - 카드, 섹션 제목 - titleLarge: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + titleLarge: TextStyle( + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: -0.2, height: 1.4, ), titleMedium: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: -0.1, height: 1.4, ), titleSmall: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0, @@ -98,21 +97,21 @@ class AppTheme { // 본문 텍스트 bodyLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 16, fontWeight: FontWeight.w400, letterSpacing: 0.1, height: 1.5, ), bodyMedium: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 14, fontWeight: FontWeight.w400, letterSpacing: 0.1, height: 1.5, ), bodySmall: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 12, fontWeight: FontWeight.w400, letterSpacing: 0.2, @@ -121,21 +120,21 @@ class AppTheme { // 라벨 텍스트 labelLarge: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.1, height: 1.4, ), labelMedium: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.2, height: 1.4, ), labelSmall: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.2, @@ -257,14 +256,14 @@ class AppTheme { // 스위치 스타일 switchTheme: SwitchThemeData( - thumbColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.primaryColor; } return Colors.white; }), - trackColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.secondaryColor.withValues(alpha: 0.5); } return AppColors.borderColor; @@ -273,8 +272,8 @@ class AppTheme { // 체크박스 스타일 checkboxTheme: CheckboxThemeData( - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.primaryColor; } return Colors.transparent; @@ -287,8 +286,8 @@ class AppTheme { // 라디오 버튼 스타일 radioTheme: RadioThemeData( - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { return AppColors.primaryColor; } return AppColors.textSecondary; @@ -311,12 +310,12 @@ class AppTheme { labelColor: AppColors.primaryColor, unselectedLabelColor: AppColors.textSecondary, indicatorColor: AppColors.primaryColor, - labelStyle: const TextStyle( + labelStyle: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.1, ), - unselectedLabelStyle: const TextStyle( + unselectedLabelStyle: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, letterSpacing: 0.1, 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/utils/haptic_feedback_helper.dart b/lib/utils/haptic_feedback_helper.dart index 1062d73..9cbde71 100644 --- a/lib/utils/haptic_feedback_helper.dart +++ b/lib/utils/haptic_feedback_helper.dart @@ -4,42 +4,42 @@ import 'dart:io' show Platform; /// 햅틱 피드백을 관리하는 헬퍼 클래스 class HapticFeedbackHelper { static bool _isEnabled = true; - + /// 햅틱 피드백 활성화 여부 설정 static void setEnabled(bool enabled) { _isEnabled = enabled; } - + /// 가벼운 햅틱 피드백 static Future lightImpact() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.lightImpact(); } - + /// 중간 강도 햅틱 피드백 static Future mediumImpact() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.mediumImpact(); } - + /// 강한 햅틱 피드백 static Future heavyImpact() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.heavyImpact(); } - + /// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine) static Future selectionClick() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.selectionClick(); } - + /// 진동 패턴 (Android) static Future vibrate({int duration = 50}) async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.vibrate(); } - + /// 성공 피드백 패턴 static Future success() async { if (!_isEnabled || !_isPlatformSupported()) return; @@ -47,7 +47,7 @@ class HapticFeedbackHelper { await Future.delayed(const Duration(milliseconds: 100)); await HapticFeedback.lightImpact(); } - + /// 에러 피드백 패턴 static Future error() async { if (!_isEnabled || !_isPlatformSupported()) return; @@ -55,13 +55,13 @@ class HapticFeedbackHelper { await Future.delayed(const Duration(milliseconds: 100)); await HapticFeedback.heavyImpact(); } - + /// 경고 피드백 패턴 static Future warning() async { if (!_isEnabled || !_isPlatformSupported()) return; await HapticFeedback.mediumImpact(); } - + /// 플랫폼이 햅틱 피드백을 지원하는지 확인 static bool _isPlatformSupported() { try { @@ -71,4 +71,4 @@ class HapticFeedbackHelper { return false; } } -} \ No newline at end of file +} diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart new file mode 100644 index 0000000..c391d02 --- /dev/null +++ b/lib/utils/logger.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; + +/// 단순 로거 헬퍼 +/// - 디버그/프로파일 모드에서만 상세 로그 출력 +/// - 릴리스 모드에서는 중요한 경고/에러만 축약 출력 +class Log { + static bool get _verbose => !kReleaseMode; + + static void d(String message) { + if (_verbose) debugPrint(message); + } + + static void i(String message) { + if (_verbose) debugPrint('ℹ️ $message'); + } + + static void w(String message) { + // 경고는 릴리스에서도 간단히 남김 + debugPrint('⚠️ $message'); + } + + static void e(String message, [Object? error, StackTrace? stack]) { + final suffix = error != null ? ' | $error' : ''; + debugPrint('❌ $message$suffix'); + if (_verbose && stack != null) debugPrint(stack.toString()); + } +} diff --git a/lib/utils/memory_manager.dart b/lib/utils/memory_manager.dart index b7f6d57..fc9b17e 100644 --- a/lib/utils/memory_manager.dart +++ b/lib/utils/memory_manager.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'logger.dart'; import 'dart:async'; /// 메모리 관리를 위한 헬퍼 클래스 @@ -7,19 +8,19 @@ class MemoryManager { static final MemoryManager _instance = MemoryManager._internal(); factory MemoryManager() => _instance; MemoryManager._internal(); - + // 캐시 관리 final Map _cache = {}; final int _maxCacheSize = 100; final Duration _defaultTTL = const Duration(minutes: 5); - + // 이미지 캐시 관리 static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB static const int maxImageCacheCount = 100; - + // 위젯 참조 추적 final Map> _widgetReferences = {}; - + /// 캐시에 데이터 저장 void cacheData({ required String key, @@ -27,86 +28,85 @@ class MemoryManager { Duration? ttl, }) { _cleanupExpiredCache(); - + if (_cache.length >= _maxCacheSize) { _evictOldestEntry(); } - + _cache[key] = _CacheEntry( data: data, timestamp: DateTime.now(), ttl: ttl ?? _defaultTTL, ); } - + /// 캐시에서 데이터 가져오기 T? getCachedData(String key) { final entry = _cache[key]; if (entry == null) return null; - + if (entry.isExpired) { _cache.remove(key); return null; } - + entry.lastAccess = DateTime.now(); return entry.data as T?; } - + /// 캐시 비우기 void clearCache() { _cache.clear(); if (kDebugMode) { - print('🧹 메모리 캐시가 비워졌습니다.'); + Log.d('🧹 메모리 캐시가 비워졌습니다.'); } } - + /// 특정 패턴의 캐시 제거 void clearCacheByPattern(String pattern) { - final keysToRemove = _cache.keys - .where((key) => key.contains(pattern)) - .toList(); - + final keysToRemove = + _cache.keys.where((key) => key.contains(pattern)).toList(); + for (final key in keysToRemove) { _cache.remove(key); } } - + /// 만료된 캐시 정리 void _cleanupExpiredCache() { final expiredKeys = _cache.entries .where((entry) => entry.value.isExpired) .map((entry) => entry.key) .toList(); - + for (final key in expiredKeys) { _cache.remove(key); } } - + /// 가장 오래된 캐시 항목 제거 void _evictOldestEntry() { if (_cache.isEmpty) return; - + var oldestKey = _cache.keys.first; var oldestTime = _cache[oldestKey]!.lastAccess; - + for (final entry in _cache.entries) { if (entry.value.lastAccess.isBefore(oldestTime)) { oldestKey = entry.key; oldestTime = entry.value.lastAccess; } } - + _cache.remove(oldestKey); } - + /// 이미지 캐시 최적화 static void optimizeImageCache() { PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount; PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize; } - + /// 이미지 캐시 상태 확인 static ImageCacheStatus getImageCacheStatus() { final cache = PaintingBinding.instance.imageCache; @@ -117,33 +117,31 @@ class MemoryManager { maximumSizeBytes: cache.maximumSizeBytes, ); } - + /// 이미지 캐시 비우기 static void clearImageCache() { PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clearLiveImages(); if (kDebugMode) { - print('🖼️ 이미지 캐시가 비워졌습니다.'); + Log.d('🖼️ 이미지 캐시가 비워졌습니다.'); } } - + /// 위젯 참조 추적 void trackWidget(String key, State widget) { _widgetReferences[key] = WeakReference(widget); } - + /// 위젯 참조 제거 void untrackWidget(String key) { _widgetReferences.remove(key); } - + /// 살아있는 위젯 수 확인 int getAliveWidgetCount() { - return _widgetReferences.values - .where((ref) => ref.target != null) - .length; + return _widgetReferences.values.where((ref) => ref.target != null).length; } - + /// 메모리 압박 시 대응 void handleMemoryPressure() { // 캐시 50% 제거 @@ -151,43 +149,43 @@ class MemoryManager { for (final key in keysToRemove) { _cache.remove(key); } - + // 이미지 캐시 축소 final imageCache = PaintingBinding.instance.imageCache; imageCache.maximumSize = maxImageCacheCount ~/ 2; imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; - + if (kDebugMode) { - print('⚠️ 메모리 압박 대응: 캐시 크기 감소'); + Log.w('메모리 압박 대응: 캐시 크기 감소'); } } - + /// 자동 메모리 정리 시작 Timer? _cleanupTimer; - + void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) { _cleanupTimer?.cancel(); _cleanupTimer = Timer.periodic(interval, (_) { _cleanupExpiredCache(); - + // 죽은 위젯 참조 제거 final deadKeys = _widgetReferences.entries .where((entry) => entry.value.target == null) .map((entry) => entry.key) .toList(); - + for (final key in deadKeys) { _widgetReferences.remove(key); } }); } - + /// 자동 메모리 정리 중지 void stopAutoCleanup() { _cleanupTimer?.cancel(); _cleanupTimer = null; } - + /// 메모리 사용량 리포트 Map getMemoryReport() { return { @@ -206,13 +204,13 @@ class _CacheEntry { final DateTime timestamp; final Duration ttl; DateTime lastAccess; - + _CacheEntry({ required this.data, required this.timestamp, required this.ttl, }) : lastAccess = timestamp; - + bool get isExpired => DateTime.now().difference(timestamp) > ttl; } @@ -222,25 +220,26 @@ class ImageCacheStatus { final int maximumSize; final int currentSizeBytes; final int maximumSizeBytes; - + ImageCacheStatus({ required this.currentSize, required this.maximumSize, required this.currentSizeBytes, required this.maximumSizeBytes, }); - + double get sizeUsagePercentage => (currentSize / maximumSize) * 100; - double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100; - + double get bytesUsagePercentage => + (currentSizeBytes / maximumSizeBytes) * 100; + Map toJson() => { - 'currentSize': currentSize, - 'maximumSize': maximumSize, - 'currentSizeBytes': currentSizeBytes, - 'maximumSizeBytes': maximumSizeBytes, - 'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2), - 'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2), - }; + 'currentSize': currentSize, + 'maximumSize': maximumSize, + 'currentSizeBytes': currentSizeBytes, + 'maximumSizeBytes': maximumSizeBytes, + 'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2), + 'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2), + }; } /// 메모리 효율적인 리스트 뷰 @@ -249,7 +248,7 @@ class MemoryEfficientListView extends StatefulWidget { final Widget Function(BuildContext, T) itemBuilder; final int cacheExtent; final ScrollPhysics? physics; - + const MemoryEfficientListView({ super.key, required this.items, @@ -257,23 +256,21 @@ class MemoryEfficientListView extends StatefulWidget { this.cacheExtent = 250, this.physics, }); - + @override - State> createState() => + State> createState() => _MemoryEfficientListViewState(); } -class _MemoryEfficientListViewState - extends State> +class _MemoryEfficientListViewState extends State> with AutomaticKeepAliveClientMixin { - @override bool get wantKeepAlive => false; - + @override Widget build(BuildContext context) { super.build(context); - + return ListView.builder( itemCount: widget.items.length, cacheExtent: widget.cacheExtent.toDouble(), @@ -283,4 +280,4 @@ class _MemoryEfficientListViewState }, ); } -} \ No newline at end of file +} diff --git a/lib/utils/performance_optimizer.dart b/lib/utils/performance_optimizer.dart index 4568720..5d2c4d0 100644 --- a/lib/utils/performance_optimizer.dart +++ b/lib/utils/performance_optimizer.dart @@ -1,23 +1,25 @@ import 'package:flutter/foundation.dart'; +import 'logger.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'dart:async'; /// 성능 최적화를 위한 유틸리티 클래스 class PerformanceOptimizer { - static final PerformanceOptimizer _instance = PerformanceOptimizer._internal(); + static final PerformanceOptimizer _instance = + PerformanceOptimizer._internal(); factory PerformanceOptimizer() => _instance; PerformanceOptimizer._internal(); - + // 프레임 타이밍 정보 final List _frameTimings = []; bool _isMonitoring = false; - + /// 프레임 성능 모니터링 시작 void startFrameMonitoring() { if (_isMonitoring) return; _isMonitoring = true; - + SchedulerBinding.instance.addTimingsCallback((timings) { _frameTimings.addAll(timings); // 최근 100개 프레임만 유지 @@ -26,27 +28,27 @@ class PerformanceOptimizer { } }); } - + /// 프레임 성능 모니터링 중지 void stopFrameMonitoring() { if (!_isMonitoring) return; _isMonitoring = false; SchedulerBinding.instance.addTimingsCallback((_) {}); } - + /// 평균 FPS 계산 double getAverageFPS() { if (_frameTimings.isEmpty) return 0.0; - + double totalDuration = 0; for (final timing in _frameTimings) { totalDuration += timing.totalSpan.inMicroseconds; } - + final averageDuration = totalDuration / _frameTimings.length; return 1000000 / averageDuration; // microseconds to FPS } - + /// 메모리 사용량 모니터링 static Future getMemoryInfo() async { // Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로 @@ -57,7 +59,7 @@ class PerformanceOptimizer { capacity: imageCache.maximumSizeBytes, ); } - + /// 위젯 재빌드 최적화를 위한 데바운서 static Timer? _debounceTimer; static void debounce( @@ -67,7 +69,7 @@ class PerformanceOptimizer { _debounceTimer?.cancel(); _debounceTimer = Timer(delay, callback); } - + /// 스로틀링 - 지정된 시간 간격으로만 실행 static DateTime? _lastThrottleTime; static void throttle( @@ -81,7 +83,7 @@ class PerformanceOptimizer { callback(); } } - + /// 무거운 연산을 별도 Isolate에서 실행 static Future runInIsolate( ComputeCallback callback, @@ -89,7 +91,7 @@ class PerformanceOptimizer { ) async { return await compute(callback, parameter); } - + /// 레이지 로딩을 위한 페이지네이션 헬퍼 static List paginate({ required List items, @@ -98,13 +100,14 @@ class PerformanceOptimizer { }) { final startIndex = page * pageSize; final endIndex = (startIndex + pageSize).clamp(0, items.length); - + if (startIndex >= items.length) return []; return items.sublist(startIndex, endIndex); } - + /// 이미지 최적화 - 메모리 효율적인 크기로 조정 - static double getOptimalImageSize(BuildContext context, { + static double getOptimalImageSize( + BuildContext context, { required double originalSize, double maxSize = 1000, }) { @@ -113,53 +116,53 @@ class PerformanceOptimizer { final maxDimension = screenSize.width > screenSize.height ? screenSize.width : screenSize.height; - + final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize); return optimalSize < originalSize ? optimalSize : originalSize; } - + /// 위젯 키 최적화 static Key generateOptimizedKey(String prefix, dynamic identifier) { return ValueKey('${prefix}_$identifier'); } - + /// 애니메이션 최적화 - 보이지 않는 애니메이션 중지 static bool shouldAnimateWidget(BuildContext context) { final mediaQuery = MediaQuery.of(context); return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation; } - + /// 스크롤 성능 최적화 static ScrollPhysics getOptimizedScrollPhysics() { return const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ); } - + /// 빌드 최적화를 위한 const 위젯 권장사항 체크 static void checkConstOptimization() { if (kDebugMode) { - print('💡 성능 최적화 팁:'); - print('1. 가능한 모든 위젯에 const 사용'); - print('2. StatelessWidget 대신 const 생성자 사용'); - print('3. 큰 리스트는 ListView.builder 사용'); - print('4. 이미지는 캐싱과 함께 적절한 크기로 로드'); - print('5. 애니메이션은 AnimatedBuilder 사용'); + Log.i('💡 성능 최적화 팁:\n' + '1. 가능한 모든 위젯에 const 사용\n' + '2. StatelessWidget 대신 const 생성자 사용\n' + '3. 큰 리스트는 ListView.builder 사용\n' + '4. 이미지는 캐싱과 함께 적절한 크기로 로드\n' + '5. 애니메이션은 AnimatedBuilder 사용'); } } - + /// 메모리 누수 감지 헬퍼 static final Map _widgetCounts = {}; - + static void trackWidget(String widgetName, bool isCreated) { if (!kDebugMode) return; - - _widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) + - (isCreated ? 1 : -1); - + + _widgetCounts[widgetName] = + (_widgetCounts[widgetName] ?? 0) + (isCreated ? 1 : -1); + // 위젯이 비정상적으로 많이 생성되면 경고 if ((_widgetCounts[widgetName] ?? 0) > 100) { - print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); + Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); } } } @@ -168,16 +171,18 @@ class PerformanceOptimizer { class MemoryInfo { final int currentUsage; final int capacity; - + MemoryInfo({ required this.currentUsage, required this.capacity, }); - + double get usagePercentage => (currentUsage / capacity) * 100; - - String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB'; - String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB'; + + String get formattedUsage => + '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB'; + String get formattedCapacity => + '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB'; } /// 성능 측정 데코레이터 @@ -187,17 +192,17 @@ class PerformanceMeasure { required Future Function() operation, }) async { if (!kDebugMode) return await operation(); - + final stopwatch = Stopwatch()..start(); try { final result = await operation(); stopwatch.stop(); - print('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms'); + Log.d('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms'); return result; } catch (e) { stopwatch.stop(); - print('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms - $e'); + Log.e('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms', e); rethrow; } } -} \ No newline at end of file +} diff --git a/lib/utils/platform_helper.dart b/lib/utils/platform_helper.dart index 06f00a4..cb41ea4 100644 --- a/lib/utils/platform_helper.dart +++ b/lib/utils/platform_helper.dart @@ -2,23 +2,23 @@ import 'package:flutter/foundation.dart'; class PlatformHelper { static bool get isWeb => kIsWeb; - + static bool get isIOS { if (kIsWeb) return false; return defaultTargetPlatform == TargetPlatform.iOS; } - + static bool get isAndroid { if (kIsWeb) return false; return defaultTargetPlatform == TargetPlatform.android; } - + static bool get isMobile => isIOS || isAndroid; - + static bool get isDesktop { if (kIsWeb) return false; return defaultTargetPlatform == TargetPlatform.linux || - defaultTargetPlatform == TargetPlatform.macOS || - defaultTargetPlatform == TargetPlatform.windows; + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows; } -} \ No newline at end of file +} diff --git a/lib/utils/reduce_motion.dart b/lib/utils/reduce_motion.dart new file mode 100644 index 0000000..756da6f --- /dev/null +++ b/lib/utils/reduce_motion.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; + +/// 접근성 설정에 따른 모션 축소 여부 헬퍼 +class ReduceMotion { + /// 플랫폼 접근성 설정을 기반으로 모션 축소 여부 반환 (context 없이 사용) + static bool platform() { + final features = + WidgetsBinding.instance.platformDispatcher.accessibilityFeatures; + // disableAnimations 신뢰 + return features.disableAnimations; + } + + /// MediaQuery/플랫폼 정보를 활용해 런타임에서 모션 축소 여부 반환 + static bool isEnabled(BuildContext context) { + final mq = MediaQuery.maybeOf(context); + if (mq != null) { + // accessibleNavigation == 사용자가 단순한 네비게이션/애니메이션 선호 + if (mq.accessibleNavigation) return true; + } + return platform(); + } + + /// 모션 강도 스케일 유틸리티 + static double scale(BuildContext context, + {required double normal, required double reduced}) { + return isEnabled(context) ? reduced : normal; + } + + /// 파티클 개수 등 정수 스케일링 + static int count(BuildContext context, + {required int normal, required int reduced}) { + return isEnabled(context) ? reduced : normal; + } +} diff --git a/lib/utils/sms_scan/category_icon_mapper.dart b/lib/utils/sms_scan/category_icon_mapper.dart index 3d30f93..9be016c 100644 --- a/lib/utils/sms_scan/category_icon_mapper.dart +++ b/lib/utils/sms_scan/category_icon_mapper.dart @@ -50,4 +50,4 @@ class CategoryIconMapper { return 16.0; } } -} \ No newline at end of file +} diff --git a/lib/utils/sms_scan/date_formatter.dart b/lib/utils/sms_scan/date_formatter.dart index 2d8353e..f8adec9 100644 --- a/lib/utils/sms_scan/date_formatter.dart +++ b/lib/utils/sms_scan/date_formatter.dart @@ -26,7 +26,7 @@ class SmsDateFormatter { ) { // 주기에 따라 다음 결제일 예측 DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now); - + if (predictedDate != null) { final daysUntil = predictedDate.difference(now).inDays; return AppLocalizations.of(context).nextBillingDateEstimated( @@ -34,7 +34,7 @@ class SmsDateFormatter { daysUntil, ); } - + return '다음 결제일 확인 필요 (과거 날짜)'; } @@ -78,7 +78,7 @@ class SmsDateFormatter { // 다음 월간 결제일 계산 static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) { int day = lastDate.day; - + // 현재 월의 마지막 날을 초과하는 경우 조정 final lastDay = DateTime(now.year, now.month + 1, 0).day; if (day > lastDay) { @@ -101,7 +101,7 @@ class SmsDateFormatter { // 다음 연간 결제일 계산 static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) { int day = lastDate.day; - + // 해당 월의 마지막 날을 초과하는 경우 조정 final lastDay = DateTime(now.year, lastDate.month + 1, 0).day; if (day > lastDay) { @@ -162,4 +162,4 @@ class SmsDateFormatter { static String getRepeatCountText(BuildContext context, int count) { return AppLocalizations.of(context).repeatCountDetected(count); } -} \ No newline at end of file +} diff --git a/lib/utils/subscription_category_helper.dart b/lib/utils/subscription_category_helper.dart index 55dda43..78060e8 100644 --- a/lib/utils/subscription_category_helper.dart +++ b/lib/utils/subscription_category_helper.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; -import '../services/subscription_url_matcher.dart'; import '../services/url_matcher/data/legacy_service_data.dart'; /// 구독 서비스를 카테고리별로 구분하는 도우미 클래스 @@ -86,8 +85,8 @@ class SubscriptionCategoryHelper { categorizedSubscriptions['shoppingEcommerce']!.add(subscription); } // 프로그래밍 - else if (_isInCategory(subscription.serviceName, - LegacyServiceData.programmingServices)) { + else if (_isInCategory( + subscription.serviceName, LegacyServiceData.programmingServices)) { if (!categorizedSubscriptions.containsKey('programming')) { categorizedSubscriptions['programming'] = []; } diff --git a/lib/widgets/add_subscription/add_subscription_app_bar.dart b/lib/widgets/add_subscription/add_subscription_app_bar.dart index 4be1c8b..d6d28d2 100644 --- a/lib/widgets/add_subscription/add_subscription_app_bar.dart +++ b/lib/widgets/add_subscription/add_subscription_app_bar.dart @@ -6,7 +6,8 @@ import '../../controllers/add_subscription_controller.dart'; import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 App Bar -class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { +class AddSubscriptionAppBar extends StatelessWidget + implements PreferredSizeWidget { final AddSubscriptionController controller; final double scrollOffset; final VoidCallback onScanSMS; @@ -101,4 +102,4 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart index 1ab39e2..b1cf6c5 100644 --- a/lib/widgets/add_subscription/add_subscription_event_section.dart +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; import '../../theme/app_colors.dart'; -import '../../l10n/app_localizations.dart'; /// 구독 추가 화면의 이벤트/할인 섹션 class AddSubscriptionEventSection extends StatelessWidget { @@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget { color: AppColors.glassBorder.withValues(alpha: 0.1), width: 1, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, - offset: const Offset(0, 4), + offset: Offset(0, 4), ), ], ), @@ -66,7 +65,8 @@ class AddSubscriptionEventSection extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: controller.gradientColors[0].withValues(alpha: 0.1), + color: controller.gradientColors[0] + .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -122,7 +122,7 @@ class AddSubscriptionEventSection extends StatelessWidget { ), ], ), - + // 이벤트 활성화 시 추가 필드 표시 AnimatedContainer( duration: const Duration(milliseconds: 300), @@ -146,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget { ), child: Row( children: [ - Icon( + const Icon( Icons.info_outline_rounded, color: AppColors.infoColor, size: 20, @@ -155,7 +155,8 @@ class AddSubscriptionEventSection extends StatelessWidget { Expanded( child: Builder( builder: (context) { - final locale = Localizations.localeOf(context); + final locale = + Localizations.localeOf(context); String infoText; switch (locale.languageCode) { case 'ko': @@ -168,11 +169,12 @@ class AddSubscriptionEventSection extends StatelessWidget { infoText = '设置折扣或促销价格'; break; default: - infoText = 'Set up discount or promotion price'; + infoText = + 'Set up discount or promotion price'; } return Text( infoText, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, fontWeight: FontWeight.w500, @@ -185,7 +187,7 @@ class AddSubscriptionEventSection extends StatelessWidget { ), ), const SizedBox(height: 20), - + // 이벤트 기간 Builder( builder: (context) { @@ -216,8 +218,10 @@ class AddSubscriptionEventSection extends StatelessWidget { setState(() { controller.eventStartDate = date; // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 - if (date != null && controller.eventEndDate == null) { - controller.eventEndDate = date.add(const Duration(days: 30)); + if (date != null && + controller.eventEndDate == null) { + controller.eventEndDate = + date.add(const Duration(days: 30)); } }); }, @@ -233,17 +237,18 @@ class AddSubscriptionEventSection extends StatelessWidget { }, ), const SizedBox(height: 20), - + // 이벤트 가격 Builder( builder: (BuildContext innerContext) { // 현재 로케일 확인 - final currentLocale = Localizations.localeOf(innerContext); - + final currentLocale = + Localizations.localeOf(innerContext); + // 로케일에 따라 직접 텍스트 설정 String eventPriceLabel; String eventPriceHint; - + switch (currentLocale.languageCode) { case 'ko': eventPriceLabel = '이벤트 가격'; @@ -261,7 +266,7 @@ class AddSubscriptionEventSection extends StatelessWidget { eventPriceLabel = 'Event Price'; eventPriceHint = 'Enter discounted price'; } - + return CurrencyInputField( controller: controller.eventPriceController, currency: controller.currency, @@ -280,4 +285,4 @@ class AddSubscriptionEventSection extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/add_subscription/add_subscription_header.dart b/lib/widgets/add_subscription/add_subscription_header.dart index 1b52cd1..b91cea4 100644 --- a/lib/widgets/add_subscription/add_subscription_header.dart +++ b/lib/widgets/add_subscription/add_subscription_header.dart @@ -86,4 +86,4 @@ class AddSubscriptionHeader extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/add_subscription/add_subscription_save_button.dart b/lib/widgets/add_subscription/add_subscription_save_button.dart index 829b551..6de05cc 100644 --- a/lib/widgets/add_subscription/add_subscription_save_button.dart +++ b/lib/widgets/add_subscription/add_subscription_save_button.dart @@ -40,8 +40,8 @@ class AddSubscriptionSaveButton extends StatelessWidget { child: PrimaryButton( text: AppLocalizations.of(context).addSubscriptionButton, icon: Icons.add_circle_outline, - onPressed: controller.isLoading - ? null + onPressed: controller.isLoading + ? null : () => controller.saveSubscription(setState: setState), isLoading: controller.isLoading, backgroundColor: const Color(0xFF3B82F6), @@ -50,4 +50,4 @@ class AddSubscriptionSaveButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/analysis_badge.dart b/lib/widgets/analysis/analysis_badge.dart index 5c5ed09..0317577 100644 --- a/lib/widgets/analysis/analysis_badge.dart +++ b/lib/widgets/analysis/analysis_badge.dart @@ -5,7 +5,6 @@ import '../../models/subscription_model.dart'; import '../../services/currency_util.dart'; import '../../providers/locale_provider.dart'; import '../../theme/app_colors.dart'; -import '../../l10n/app_localizations.dart'; /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 class AnalysisBadge extends StatelessWidget { @@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget { color: borderColor, width: 2, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, @@ -69,13 +68,17 @@ class AnalysisBadge extends StatelessWidget { String displayText = amountText; if (amountText.length > 12) { // 괄호 안의 내용 제거 - displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim(); + displayText = + amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim(); } if (displayText.length > 10) { // 통화 기호만 남기고 숫자만 표시 - final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency); - displayText = displayText.replaceAll(currencySymbol, '').trim(); - displayText = '$currencySymbol${displayText.substring(0, 6)}...'; + final currencySymbol = + CurrencyUtil.getCurrencySymbol(subscription.currency); + displayText = + displayText.replaceAll(currencySymbol, '').trim(); + displayText = + '$currencySymbol${displayText.substring(0, 6)}...'; } return Text( displayText, @@ -93,4 +96,4 @@ class AnalysisBadge extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/analysis_screen_spacer.dart b/lib/widgets/analysis/analysis_screen_spacer.dart index f8db192..138d6f0 100644 --- a/lib/widgets/analysis/analysis_screen_spacer.dart +++ b/lib/widgets/analysis/analysis_screen_spacer.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; /// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리 class AnalysisScreenSpacer extends StatelessWidget { final double height; - + const AnalysisScreenSpacer({ super.key, this.height = 24, @@ -16,4 +16,4 @@ class AnalysisScreenSpacer extends StatelessWidget { child: SizedBox(height: height), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/event_analysis_card.dart b/lib/widgets/analysis/event_analysis_card.dart index 2dfe07e..1b2e01f 100644 --- a/lib/widgets/analysis/event_analysis_card.dart +++ b/lib/widgets/analysis/event_analysis_card.dart @@ -48,10 +48,12 @@ class EventAnalysisCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: AppLocalizations.of(context).eventDiscountStatus, + text: AppLocalizations.of(context) + .eventDiscountStatus, style: const TextStyle( fontSize: 18, ), @@ -79,7 +81,10 @@ class EventAnalysisCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length), + AppLocalizations.of(context) + .servicesInProgress(provider + .activeEventSubscriptions + .length), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -97,15 +102,18 @@ class EventAnalysisCard extends StatelessWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - const Color(0xFFFF6B6B).withValues(alpha: 0.1), - const Color(0xFFFF8787).withValues(alpha: 0.1), + const Color(0xFFFF6B6B) + .withValues(alpha: 0.1), + const Color(0xFFFF8787) + .withValues(alpha: 0.1), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(8), border: Border.all( - color: const Color(0xFFFF6B6B).withValues(alpha: 0.3), + color: const Color(0xFFFF6B6B) + .withValues(alpha: 0.3), ), ), child: Row( @@ -118,10 +126,12 @@ class EventAnalysisCard extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ ThemedText( - AppLocalizations.of(context).monthlySavingAmount, + AppLocalizations.of(context) + .monthlySavingAmount, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -154,24 +164,29 @@ class EventAnalysisCard extends StatelessWidget { ), const SizedBox(height: 12), ...provider.activeEventSubscriptions.map((sub) { - final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice); - final discountRate = - ((savings / sub.originalPrice) * 100).round(); + final savings = sub.originalPrice - + (sub.eventPrice ?? sub.originalPrice); + final discountRate = + ((savings / sub.originalPrice) * 100) + .round(); return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.darkNavy.withValues(alpha: 0.05), + color: AppColors.darkNavy + .withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), border: Border.all( - color: AppColors.darkNavy.withValues(alpha: 0.1), + color: AppColors.darkNavy + .withValues(alpha: 0.1), ), ), child: Row( children: [ Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ ThemedText( sub.serviceName, @@ -184,8 +199,8 @@ class EventAnalysisCard extends StatelessWidget { Row( children: [ FutureBuilder( - future: CurrencyUtil - .formatAmount( + future: + CurrencyUtil.formatAmount( sub.originalPrice, sub.currency), builder: (context, snapshot) { @@ -194,9 +209,11 @@ class EventAnalysisCard extends StatelessWidget { snapshot.data!, style: const TextStyle( fontSize: 12, - decoration: TextDecoration - .lineThrough, - color: AppColors.navyGray, + decoration: + TextDecoration + .lineThrough, + color: AppColors + .navyGray, ), ); } @@ -211,9 +228,10 @@ class EventAnalysisCard extends StatelessWidget { ), const SizedBox(width: 8), FutureBuilder( - future: CurrencyUtil - .formatAmount( - sub.eventPrice ?? sub.originalPrice, + future: + CurrencyUtil.formatAmount( + sub.eventPrice ?? + sub.originalPrice, sub.currency), builder: (context, snapshot) { if (snapshot.hasData) { @@ -244,7 +262,8 @@ class EventAnalysisCard extends StatelessWidget { decoration: BoxDecoration( color: const Color(0xFFFF6B6B) .withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), + borderRadius: + BorderRadius.circular(4), ), child: Text( '$discountRate${AppLocalizations.of(context).discountPercent}', @@ -271,4 +290,4 @@ class EventAnalysisCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/monthly_expense_chart_card.dart b/lib/widgets/analysis/monthly_expense_chart_card.dart index edbe059..30eb01e 100644 --- a/lib/widgets/analysis/monthly_expense_chart_card.dart +++ b/lib/widgets/analysis/monthly_expense_chart_card.dart @@ -8,6 +8,7 @@ import '../../theme/app_colors.dart'; import '../glassmorphism_card.dart'; import '../themed_text.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/reduce_motion.dart'; /// 월별 지출 현황을 차트로 보여주는 카드 위젯 class MonthlyExpenseChartCard extends StatelessWidget { @@ -23,7 +24,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { /// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰) double _calculateChartMaxY(double maxValue, String locale) { final currency = CurrencyUtil.getDefaultCurrency(locale); - + if (currency == 'KRW' || currency == 'JPY') { // 소수점이 없는 통화 (원화, 엔화) if (maxValue <= 0) return 100000; @@ -33,9 +34,10 @@ class MonthlyExpenseChartCard extends StatelessWidget { if (maxValue <= 200000) return 200000; if (maxValue <= 500000) return 500000; if (maxValue <= 1000000) return 1000000; - + // 큰 금액은 자릿수에 맞춰 반올림 - final magnitude = math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble(); + final magnitude = + math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble(); return ((maxValue / magnitude).ceil() * magnitude).toDouble(); } else { // 소수점이 있는 통화 (달러, 위안) @@ -47,7 +49,7 @@ class MonthlyExpenseChartCard extends StatelessWidget { if (maxValue <= 250) return 250.0; if (maxValue <= 500) return 500.0; if (maxValue <= 1000) return 1000.0; - + // 큰 금액은 100 단위로 반올림 return ((maxValue / 100).ceil() * 100).toDouble(); } @@ -153,108 +155,114 @@ class MonthlyExpenseChartCard extends StatelessWidget { ), ), const SizedBox(height: 20), - // 바 차트 - AspectRatio( - aspectRatio: 1.6, - child: BarChart( - BarChartData( - alignment: BarChartAlignment.spaceAround, - maxY: _calculateChartMaxY( - monthlyData.fold( - 0, - (max, data) => math.max( - max, data['totalExpense'] as double)), - locale - ), - barGroups: _getMonthlyBarGroups(locale), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: _calculateGridInterval( - _calculateChartMaxY( - monthlyData.fold( - 0, - (max, data) => math.max(max, - data['totalExpense'] as double)), - locale - ), - CurrencyUtil.getDefaultCurrency(locale) + // 바 차트 (RepaintBoundary로 페인트 분리) + RepaintBoundary( + child: AspectRatio( + aspectRatio: 1.6, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: _calculateChartMaxY( + monthlyData.fold( + 0, + (max, data) => math.max( + max, data['totalExpense'] as double)), + locale), + barGroups: _getMonthlyBarGroups(locale), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: _calculateGridInterval( + _calculateChartMaxY( + monthlyData.fold( + 0, + (max, data) => math.max(max, + data['totalExpense'] as double)), + locale), + CurrencyUtil.getDefaultCurrency(locale)), + getDrawingHorizontalLine: (value) { + return FlLine( + color: + AppColors.navyGray.withValues(alpha: 0.1), + strokeWidth: 1, + ); + }, ), - getDrawingHorizontalLine: (value) { - return FlLine( - color: AppColors.navyGray.withValues(alpha: 0.1), - strokeWidth: 1, - ); - }, - ), - titlesData: FlTitlesData( - show: true, - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: ThemedText.caption( - text: monthlyData[value.toInt()] - ['monthName'], - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + titlesData: FlTitlesData( + show: true, + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: ThemedText.caption( + text: monthlyData[value.toInt()] + ['monthName'], + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), + ); + }, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + tooltipBgColor: AppColors.darkNavy, + tooltipRoundedRadius: 8, + getTooltipItem: + (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + '${monthlyData[group.x]['monthName']}\n', + const TextStyle( + color: AppColors.pureWhite, + fontWeight: FontWeight.bold, ), + children: [ + TextSpan( + text: CurrencyUtil + .formatTotalAmountWithLocale( + monthlyData[group.x] + ['totalExpense'] as double, + locale), + style: const TextStyle( + color: Color(0xFFFBBF24), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], ); }, ), ), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - borderData: FlBorderData(show: false), - barTouchData: BarTouchData( - enabled: true, - touchTooltipData: BarTouchTooltipData( - tooltipBgColor: AppColors.darkNavy, - tooltipRoundedRadius: 8, - getTooltipItem: - (group, groupIndex, rod, rodIndex) { - return BarTooltipItem( - '${monthlyData[group.x]['monthName']}\n', - const TextStyle( - color: AppColors.pureWhite, - fontWeight: FontWeight.bold, - ), - children: [ - TextSpan( - text: CurrencyUtil.formatTotalAmountWithLocale( - monthlyData[group.x]['totalExpense'] - as double, - locale), - style: const TextStyle( - color: Color(0xFFFBBF24), - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ); - }, - ), ), + swapAnimationDuration: ReduceMotion.isEnabled(context) + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), + swapAnimationCurve: Curves.easeOut, ), ), ), const SizedBox(height: 16), Center( child: ThemedText.caption( - text: AppLocalizations.of(context).monthlySubscriptionExpense, + text: AppLocalizations.of(context) + .monthlySubscriptionExpense, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -270,4 +278,4 @@ class MonthlyExpenseChartCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/subscription_pie_chart_card.dart b/lib/widgets/analysis/subscription_pie_chart_card.dart index 17cf530..37a40c4 100644 --- a/lib/widgets/analysis/subscription_pie_chart_card.dart +++ b/lib/widgets/analysis/subscription_pie_chart_card.dart @@ -10,6 +10,7 @@ import '../themed_text.dart'; import 'analysis_badge.dart'; import '../../l10n/app_localizations.dart'; import '../../providers/locale_provider.dart'; +import '../../utils/reduce_motion.dart'; /// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯 class SubscriptionPieChartCard extends StatefulWidget { @@ -23,14 +24,15 @@ class SubscriptionPieChartCard extends StatefulWidget { }); @override - State createState() => _SubscriptionPieChartCardState(); + State createState() => + _SubscriptionPieChartCardState(); } class _SubscriptionPieChartCardState extends State { int _touchedIndex = -1; late Future> _pieSectionsFuture; String? _lastLocale; - + static const _chartColors = [ Color(0xFF3B82F6), Color(0xFF10B981), @@ -52,7 +54,7 @@ class _SubscriptionPieChartCardState extends State { super.didUpdateWidget(oldWidget); // subscriptions나 locale이 변경된 경우만 Future 재생성 final currentLocale = context.read().locale.languageCode; - if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) || + if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) || _lastLocale != currentLocale) { _initializeFuture(); } @@ -66,7 +68,7 @@ class _SubscriptionPieChartCardState extends State { bool _listEquals(List a, List b) { if (a.length != b.length) return false; for (int i = 0; i < a.length; i++) { - if (a[i].id != b[i].id || + if (a[i].id != b[i].id || a[i].currentPrice != b[i].currentPrice || a[i].currency != b[i].currency || a[i].serviceName != b[i].serviceName) { @@ -78,7 +80,6 @@ class _SubscriptionPieChartCardState extends State { // 파이 차트 섹션 데이터 (언어별 기본 통화로 환산) Future> _getPieSections() async { - if (widget.subscriptions.isEmpty) return []; // 현재 locale 가져오기 @@ -91,17 +92,19 @@ class _SubscriptionPieChartCardState extends State { // 각 구독의 현재 가격을 언어별 기본 통화로 환산 for (var subscription in widget.subscriptions) { double value = subscription.currentPrice; - + if (subscription.currency == defaultCurrency) { // 이미 기본 통화인 경우 그대로 사용 sectionValues.add(value); } else if (subscription.currency == 'USD') { // USD를 기본 통화로 변환 - final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency); + final converted = await ExchangeRateService() + .convertUsdToTarget(value, defaultCurrency); sectionValues.add(converted ?? value); } else if (defaultCurrency == 'USD') { // 기본 통화가 USD인 경우 다른 통화를 USD로 변환 - final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency); + final converted = await ExchangeRateService() + .convertTargetToUsd(value, subscription.currency); sectionValues.add(converted ?? value); } else { // 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우) @@ -111,7 +114,7 @@ class _SubscriptionPieChartCardState extends State { // 총합 계산 double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); - + // 총합이 0이면 빈 배열 반환 if (sectionsTotal == 0) return []; @@ -138,17 +141,17 @@ class _SubscriptionPieChartCardState extends State { badgePositionPercentageOffset: .98, ); }); - + return sections; } // 배지 위젯 생성 Widget _createBadgeWidget(int index) { if (index >= widget.subscriptions.length) return const SizedBox.shrink(); - + final subscription = widget.subscriptions[index]; final colorIndex = index % _chartColors.length; - + return IgnorePointer( child: AnalysisBadge( size: 40, @@ -159,24 +162,27 @@ class _SubscriptionPieChartCardState extends State { } // 터치 상태를 반영한 섹션 데이터 생성 - List _applyTouchedState(List sections) { + List _applyTouchedState( + List sections) { return List.generate(sections.length, (i) { final section = sections[i]; final isTouched = _touchedIndex == i; final fontSize = isTouched ? 16.0 : 12.0; final radius = isTouched ? 105.0 : 100.0; - + return PieChartSectionData( value: section.value, title: section.title, - titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.bold, - color: AppColors.pureWhite, - shadows: const [ - Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) - ], - ), + titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? + TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: AppColors.pureWhite, + shadows: const [ + Shadow( + color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) + ], + ), color: section.color, radius: radius, titlePositionPercentageOffset: section.titlePositionPercentageOffset, @@ -217,18 +223,20 @@ class _SubscriptionPieChartCardState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ ThemedText.headline( - text: AppLocalizations.of(context).subscriptionServiceRatio, + text: AppLocalizations.of(context) + .subscriptionServiceRatio, style: const TextStyle( fontSize: 18, ), ), FutureBuilder( future: CurrencyUtil.getExchangeRateInfoForLocale( - context.watch().locale.languageCode - ), + context + .watch() + .locale + .languageCode), builder: (context, snapshot) { - if (snapshot.hasData && - snapshot.data!.isNotEmpty) { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -236,15 +244,15 @@ class _SubscriptionPieChartCardState extends State { ), decoration: BoxDecoration( color: const Color(0xFFE5F2FF), - borderRadius: - BorderRadius.circular(4), + borderRadius: BorderRadius.circular(4), border: Border.all( color: const Color(0xFFBFDBFE), width: 1, ), ), child: Text( - AppLocalizations.of(context).exchangeRateFormat(snapshot.data!), + AppLocalizations.of(context) + .exchangeRateFormat(snapshot.data!), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -272,7 +280,8 @@ class _SubscriptionPieChartCardState extends State { height: 250, child: Center( child: ThemedText( - AppLocalizations.of(context).noSubscriptionServices, + AppLocalizations.of(context) + .noSubscriptionServices, style: const TextStyle( fontSize: 16, ), @@ -284,70 +293,89 @@ class _SubscriptionPieChartCardState extends State { child: FutureBuilder>( future: _pieSectionsFuture, builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { + if (snapshot.connectionState == + ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(), ); } - - if (!snapshot.hasData || snapshot.data!.isEmpty) { + + if (!snapshot.hasData || + snapshot.data!.isEmpty) { return Center( child: ThemedText( - AppLocalizations.of(context).noSubscriptionServices, + AppLocalizations.of(context) + .noSubscriptionServices, style: const TextStyle( fontSize: 16, ), ), ); } - - return PieChart( - PieChartData( - borderData: FlBorderData(show: false), - sectionsSpace: 2, - centerSpaceRadius: 60, - sections: _applyTouchedState(snapshot.data!), - pieTouchData: PieTouchData( - enabled: true, - touchCallback: (FlTouchEvent event, - pieTouchResponse) { - // 터치 응답이 없거나 섹션이 없는 경우 - if (pieTouchResponse == null || - pieTouchResponse.touchedSection == null) { - // 차트 밖으로 나갔을 때만 리셋 - if (_touchedIndex != -1) { - setState(() { - _touchedIndex = -1; - }); + + return RepaintBoundary( + child: PieChart( + PieChartData( + borderData: FlBorderData(show: false), + sectionsSpace: 2, + centerSpaceRadius: 60, + sections: + _applyTouchedState(snapshot.data!), + pieTouchData: PieTouchData( + enabled: true, + touchCallback: (FlTouchEvent event, + pieTouchResponse) { + // 터치 응답이 없거나 섹션이 없는 경우 + if (pieTouchResponse == null || + pieTouchResponse + .touchedSection == + null) { + // 차트 밖으로 나갔을 때만 리셋 + if (_touchedIndex != -1) { + setState(() { + _touchedIndex = -1; + }); + } + return; } - return; - } - - final touchedIndex = pieTouchResponse - .touchedSection! - .touchedSectionIndex; - - // 탭 이벤트 처리 (토글) - if (event is FlTapUpEvent) { - setState(() { - // 동일 섹션 탭하면 선택 해제, 아니면 선택 - _touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex; - }); - return; - } - - // hover 이벤트 처리 (단순 표시) - if (event is FlPointerHoverEvent || - event is FlPointerEnterEvent) { - // 현재 인덱스와 다른 경우만 업데이트 - if (_touchedIndex != touchedIndex) { + + final touchedIndex = + pieTouchResponse.touchedSection! + .touchedSectionIndex; + + // 탭 이벤트 처리 (토글) + if (event is FlTapUpEvent) { setState(() { - _touchedIndex = touchedIndex; + // 동일 섹션 탭하면 선택 해제, 아니면 선택 + _touchedIndex = + (_touchedIndex == + touchedIndex) + ? -1 + : touchedIndex; }); + return; } - } - }, + + // hover 이벤트 처리 (단순 표시) + if (event is FlPointerHoverEvent || + event is FlPointerEnterEvent) { + // 현재 인덱스와 다른 경우만 업데이트 + if (_touchedIndex != + touchedIndex) { + setState(() { + _touchedIndex = touchedIndex; + }); + } + } + }, + ), ), + swapAnimationDuration: + ReduceMotion.isEnabled(context) + ? const Duration(milliseconds: 0) + : const Duration( + milliseconds: 300), + swapAnimationCurve: Curves.easeOut, ), ); }, @@ -364,10 +392,10 @@ class _SubscriptionPieChartCardState extends State { (index) { final subscription = widget.subscriptions[index]; - final color = _chartColors[index % _chartColors.length]; + final color = + _chartColors[index % _chartColors.length]; return Padding( - padding: const EdgeInsets.only( - bottom: 4.0), + padding: const EdgeInsets.only(bottom: 4.0), child: Row( children: [ Container( @@ -385,31 +413,31 @@ class _SubscriptionPieChartCardState extends State { style: const TextStyle( fontSize: 14, ), - overflow: - TextOverflow.ellipsis, + overflow: TextOverflow.ellipsis, ), ), FutureBuilder( future: CurrencyUtil .formatSubscriptionAmountWithLocale( subscription, - context.read().locale.languageCode), + context + .read() + .locale + .languageCode), builder: (context, snapshot) { if (snapshot.hasData) { return ThemedText( snapshot.data!, style: const TextStyle( fontSize: 14, - fontWeight: - FontWeight.bold, + fontWeight: FontWeight.bold, ), ); } return const SizedBox( width: 20, height: 20, - child: - CircularProgressIndicator( + child: CircularProgressIndicator( strokeWidth: 2, ), ); @@ -430,4 +458,4 @@ class _SubscriptionPieChartCardState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/analysis/total_expense_summary_card.dart b/lib/widgets/analysis/total_expense_summary_card.dart index 2e06d94..232ad0c 100644 --- a/lib/widgets/analysis/total_expense_summary_card.dart +++ b/lib/widgets/analysis/total_expense_summary_card.dart @@ -43,185 +43,204 @@ class TotalExpenseSummaryCard extends StatelessWidget { parent: animationController, curve: const Interval(0.2, 0.8, curve: Curves.easeOut), )), - child: GlassmorphismCard( - blur: 10, - opacity: 0.1, - borderRadius: 16, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ThemedText.headline( - text: AppLocalizations.of(context).totalExpenseSummary, - style: const TextStyle( - fontSize: 18, + child: RepaintBoundary( + child: GlassmorphismCard( + blur: 10, + opacity: 0.1, + borderRadius: 16, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ThemedText.headline( + text: AppLocalizations.of(context) + .totalExpenseSummary, + style: const TextStyle( + fontSize: 18, + ), ), - ), - IconButton( - icon: const Icon(Icons.content_copy), - iconSize: 20, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () async { - final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale); - await Clipboard.setData( - ClipboardData(text: totalExpenseText)); - HapticFeedbackHelper.lightImpact(); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)), - duration: const Duration(seconds: 2), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + IconButton( + icon: const Icon(Icons.content_copy), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () async { + final totalExpenseText = + CurrencyUtil.formatTotalAmountWithLocale( + totalExpense, locale); + await Clipboard.setData( + ClipboardData(text: totalExpenseText)); + HapticFeedbackHelper.lightImpact(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context) + .totalExpenseCopied(totalExpenseText)), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + backgroundColor: AppColors.glassBackground + .withValues(alpha: 0.3), + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), ), - backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3), - margin: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - ), - ); - }, - ), - ], - ), - const SizedBox(height: 8), - ThemedText.subtitle( - text: AppLocalizations.of(context).monthlyTotalAmount, - style: const TextStyle( - fontSize: 14, + ); + }, + ), + ], ), - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedText.caption( - text: AppLocalizations.of(context).totalExpense, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - ThemedText( - CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale), - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - letterSpacing: -0.5, - ), - ), - ], - ), + const SizedBox(height: 8), + ThemedText.subtitle( + text: AppLocalizations.of(context).monthlyTotalAmount, + style: const TextStyle( + fontSize: 14, ), - const SizedBox(width: 16), - Expanded( - child: Column( - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glassBackground.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.2), - ), - ), - child: const FaIcon( - FontAwesomeIcons.listCheck, - size: 16, - color: AppColors.primaryColor, - ), + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: + AppLocalizations.of(context).totalExpense, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ThemedText.caption( - text: AppLocalizations.of(context).totalServices, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - ThemedText( - AppLocalizations.of(context).subscriptionCount(subscriptions.length), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], + ), + const SizedBox(height: 4), + ThemedText( + CurrencyUtil.formatTotalAmountWithLocale( + totalExpense, locale), + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + letterSpacing: -0.5, ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glassBackground.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.2), - ), - ), - child: const FaIcon( - FontAwesomeIcons.chartLine, - size: 16, - color: AppColors.successColor, - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ThemedText.caption( - text: AppLocalizations.of(context).averageCost, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - ThemedText( - CurrencyUtil.formatTotalAmountWithLocale( - subscriptions.isEmpty - ? 0 - : totalExpense / subscriptions.length, - locale), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], - ), - ], + ), + ], + ), ), - ), - ], - ), - ], + const SizedBox(width: 16), + Expanded( + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glassBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.glassBorder + .withValues(alpha: 0.2), + ), + ), + child: const FaIcon( + FontAwesomeIcons.listCheck, + size: 16, + color: AppColors.primaryColor, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: AppLocalizations.of(context) + .totalServices, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + ThemedText( + AppLocalizations.of(context) + .subscriptionCount( + subscriptions.length), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glassBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.glassBorder + .withValues(alpha: 0.2), + ), + ), + child: const FaIcon( + FontAwesomeIcons.chartLine, + size: 16, + color: AppColors.successColor, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: AppLocalizations.of(context) + .averageCost, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + ThemedText( + CurrencyUtil + .formatTotalAmountWithLocale( + subscriptions.isEmpty + ? 0 + : totalExpense / + subscriptions.length, + locale), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), ), ), ), @@ -230,4 +249,4 @@ class TotalExpenseSummaryCard extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/animated_page_transitions.dart b/lib/widgets/animated_page_transitions.dart index d79deed..43dc8c3 100644 --- a/lib/widgets/animated_page_transitions.dart +++ b/lib/widgets/animated_page_transitions.dart @@ -1,18 +1,23 @@ import 'package:flutter/material.dart'; import 'dart:math' as math; +import '../utils/reduce_motion.dart'; /// 슬라이드 + 페이드 전환 class SlidePageRoute extends PageRouteBuilder { final Widget page; final AxisDirection direction; - + SlidePageRoute({ required this.page, this.direction = AxisDirection.right, }) : super( pageBuilder: (context, animation, secondaryAnimation) => page, - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 300), + transitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 300), + reverseTransitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 300), transitionsBuilder: (context, animation, secondaryAnimation, child) { Offset begin; switch (direction) { @@ -29,20 +34,20 @@ class SlidePageRoute extends PageRouteBuilder { begin = const Offset(0.0, -1.0); break; } - + const end = Offset.zero; const curve = Curves.easeOutCubic; - + var tween = Tween(begin: begin, end: end).chain( CurveTween(curve: curve), ); var offsetAnimation = animation.drive(tween); - + var fadeTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: curve), ); var fadeAnimation = animation.drive(fadeTween); - + return SlideTransition( position: offsetAnimation, child: FadeTransition( @@ -58,27 +63,31 @@ class SlidePageRoute extends PageRouteBuilder { class ScalePageRoute extends PageRouteBuilder { final Widget page; final Alignment alignment; - + ScalePageRoute({ required this.page, this.alignment = Alignment.center, }) : super( pageBuilder: (context, animation, secondaryAnimation) => page, - transitionDuration: const Duration(milliseconds: 400), - reverseTransitionDuration: const Duration(milliseconds: 400), + transitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 400), + reverseTransitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 400), transitionsBuilder: (context, animation, secondaryAnimation, child) { const curve = Curves.elasticOut; - + var scaleTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: curve), ); var scaleAnimation = animation.drive(scaleTween); - + var fadeTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: Curves.easeIn), ); var fadeAnimation = animation.drive(fadeTween); - + return ScaleTransition( scale: scaleAnimation, alignment: alignment, @@ -94,25 +103,29 @@ class ScalePageRoute extends PageRouteBuilder { /// 회전 + 스케일 전환 class RotatePageRoute extends PageRouteBuilder { final Widget page; - + RotatePageRoute({required this.page}) : super( pageBuilder: (context, animation, secondaryAnimation) => page, - transitionDuration: const Duration(milliseconds: 500), - reverseTransitionDuration: const Duration(milliseconds: 500), + transitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 500), + reverseTransitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 500), transitionsBuilder: (context, animation, secondaryAnimation, child) { const curve = Curves.easeInOut; - + var rotateTween = Tween(begin: -0.5, end: 0.0).chain( CurveTween(curve: curve), ); var rotateAnimation = animation.drive(rotateTween); - + var scaleTween = Tween(begin: 0.0, end: 1.0).chain( CurveTween(curve: curve), ); var scaleAnimation = animation.drive(scaleTween); - + return Transform( alignment: Alignment.center, transform: Matrix4.identity() @@ -129,17 +142,22 @@ class RotatePageRoute extends PageRouteBuilder { class FlipPageRoute extends PageRouteBuilder { final Widget page; final bool horizontal; - + FlipPageRoute({ required this.page, this.horizontal = true, }) : super( pageBuilder: (context, animation, secondaryAnimation) => page, - transitionDuration: const Duration(milliseconds: 800), - reverseTransitionDuration: const Duration(milliseconds: 800), + transitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 800), + reverseTransitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 800), transitionsBuilder: (context, animation, secondaryAnimation, child) { - final isAnimatingForward = animation.status == AnimationStatus.forward; - + final isAnimatingForward = + animation.status == AnimationStatus.forward; + final flipAnimation = Tween( begin: 0.0, end: isAnimatingForward ? -math.pi : math.pi, @@ -147,12 +165,12 @@ class FlipPageRoute extends PageRouteBuilder { parent: animation, curve: Curves.easeInOut, )); - + return AnimatedBuilder( animation: flipAnimation, builder: (context, child) { final isShowingFront = flipAnimation.value.abs() < math.pi / 2; - + return Transform( alignment: Alignment.center, transform: Matrix4.identity() @@ -181,15 +199,19 @@ class ContainerTransformPageRoute extends PageRouteBuilder { final Widget page; final Widget startWidget; final BorderRadius? borderRadius; - + ContainerTransformPageRoute({ required this.page, required this.startWidget, this.borderRadius, }) : super( pageBuilder: (context, animation, secondaryAnimation) => page, - transitionDuration: const Duration(milliseconds: 500), - reverseTransitionDuration: const Duration(milliseconds: 500), + transitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 500), + reverseTransitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 500), transitionsBuilder: (context, animation, secondaryAnimation, child) { return Stack( children: [ @@ -208,7 +230,7 @@ class ContainerTransformPageRoute extends PageRouteBuilder { final scale = 0.5 + (0.5 * progress); final radius = borderRadius?.topLeft.x ?? 0; final currentRadius = radius * (1 - progress); - + return Transform.scale( scale: scale, child: ClipRRect( @@ -229,7 +251,7 @@ class ContainerTransformPageRoute extends PageRouteBuilder { class CustomHeroPageRoute extends PageRouteBuilder { final Widget page; final String heroTag; - + CustomHeroPageRoute({ required this.page, required this.heroTag, @@ -253,18 +275,22 @@ class CustomHeroPageRoute extends PageRouteBuilder { class SharedAxisPageRoute extends PageRouteBuilder { final Widget page; final SharedAxisTransitionType transitionType; - + SharedAxisPageRoute({ required this.page, required this.transitionType, }) : super( pageBuilder: (context, animation, secondaryAnimation) => page, - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 300), + transitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 300), + reverseTransitionDuration: ReduceMotion.platform() + ? Duration.zero + : const Duration(milliseconds: 300), transitionsBuilder: (context, animation, secondaryAnimation, child) { late final Offset begin; late final Offset end; - + switch (transitionType) { case SharedAxisTransitionType.horizontal: begin = const Offset(1.0, 0.0); @@ -279,17 +305,17 @@ class SharedAxisPageRoute extends PageRouteBuilder { end = Offset.zero; break; } - + final slideTween = Tween(begin: begin, end: end); final fadeTween = Tween(begin: 0.0, end: 1.0); final scaleTween = transitionType == SharedAxisTransitionType.scaled ? Tween(begin: 0.8, end: 1.0) : Tween(begin: 1.0, end: 1.0); - + final slideAnimation = animation.drive(slideTween); final fadeAnimation = animation.drive(fadeTween); final scaleAnimation = animation.drive(scaleTween); - + return SlideTransition( position: slideAnimation, child: FadeTransition( @@ -308,4 +334,4 @@ enum SharedAxisTransitionType { horizontal, vertical, scaled, -} \ No newline at end of file +} diff --git a/lib/widgets/animated_wave_background.dart b/lib/widgets/animated_wave_background.dart index 9fb2d8f..76141cd 100644 --- a/lib/widgets/animated_wave_background.dart +++ b/lib/widgets/animated_wave_background.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:math' as math; +import '../utils/reduce_motion.dart'; /// 웨이브 애니메이션 배경 효과를 제공하는 위젯 /// @@ -16,6 +17,8 @@ class AnimatedWaveBackground extends StatelessWidget { @override Widget build(BuildContext context) { + final reduce = ReduceMotion.isEnabled(context); + final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일 return Stack( children: [ // 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용 @@ -25,15 +28,15 @@ class AnimatedWaveBackground extends StatelessWidget { // 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성 final angle = controller.value * 2 * math.pi; // 사인 함수를 사용하여 부드러운 움직임 생성 - final xOffset = 20 * math.sin(angle); - final yOffset = 10 * math.cos(angle); + final xOffset = 20 * amp * math.sin(angle); + final yOffset = 10 * amp * math.cos(angle); return Positioned( right: -40 + xOffset, top: -60 + yOffset, child: Transform.rotate( // 회전도 선형적으로 변화하도록 수정 - angle: 0.2 * math.sin(angle * 0.5), + angle: 0.2 * amp * math.sin(angle * 0.5), child: Container( width: 200, height: 200, @@ -51,15 +54,15 @@ class AnimatedWaveBackground extends StatelessWidget { builder: (context, child) { // 첫 번째 원과 약간 다른 위상을 가지도록 설정 final angle = (controller.value * 2 * math.pi) + (math.pi / 3); - final xOffset = 20 * math.cos(angle); - final yOffset = 10 * math.sin(angle); + final xOffset = 20 * amp * math.cos(angle); + final yOffset = 10 * amp * math.sin(angle); return Positioned( left: -80 + xOffset, bottom: -70 + yOffset, child: Transform.rotate( // 반대 방향으로 회전하도록 설정 - angle: -0.3 * math.sin(angle * 0.5), + angle: -0.3 * amp * math.sin(angle * 0.5), child: Container( width: 220, height: 220, @@ -78,14 +81,14 @@ class AnimatedWaveBackground extends StatelessWidget { builder: (context, child) { // 세 번째 원은 다른 위상으로 움직이도록 설정 final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3); - final xOffset = 15 * math.sin(angle * 0.7); - final yOffset = 8 * math.cos(angle * 0.7); + final xOffset = 15 * amp * math.sin(angle * 0.7); + final yOffset = 8 * amp * math.cos(angle * 0.7); return Positioned( right: 40 + xOffset, bottom: -40 + yOffset, child: Transform.rotate( - angle: 0.4 * math.cos(angle * 0.5), + angle: 0.4 * amp * math.cos(angle * 0.5), child: Container( width: 120, height: 120, @@ -109,9 +112,8 @@ class AnimatedWaveBackground extends StatelessWidget { width: 30, height: 30, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: - 0.1 + 0.1 * pulseController.value, - ), + color: Colors.white.withValues( + alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value), borderRadius: BorderRadius.circular(15), ), ), diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index 7e7a01b..9ea1917 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart'; import '../models/subscription_model.dart'; import '../providers/navigation_provider.dart'; import '../routes/app_routes.dart'; +import '../utils/logger.dart'; import 'animated_page_transitions.dart'; import '../l10n/app_localizations.dart'; @@ -18,7 +19,7 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.clearHistoryAndGoHome(); - + await Navigator.of(context).pushNamedAndRemoveUntil( AppRoutes.main, (route) => false, @@ -30,30 +31,31 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.updateCurrentIndex(1); - + await Navigator.of(context).pushNamed(AppRoutes.analysis); } /// 구독 추가 화면으로 네비게이션 static Future toAddSubscription(BuildContext context) async { HapticFeedback.mediumImpact(); - + await Navigator.of(context).pushNamed(AppRoutes.addSubscription); } /// 구독 상세 화면으로 네비게이션 - static Future toDetail(BuildContext context, SubscriptionModel subscription) async { - print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); + static Future toDetail( + BuildContext context, SubscriptionModel subscription) async { + Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); HapticFeedback.lightImpact(); - + try { await Navigator.of(context).pushNamed( AppRoutes.subscriptionDetail, arguments: subscription, ); - print('DetailScreen 네비게이션 성공'); + Log.d('DetailScreen 네비게이션 성공'); } catch (e) { - print('DetailScreen 네비게이션 오류: $e'); + Log.e('DetailScreen 네비게이션 오류', e); } } @@ -62,7 +64,7 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.updateCurrentIndex(3); - + await Navigator.of(context).pushNamed(AppRoutes.smsScanner); } @@ -71,14 +73,14 @@ class AppNavigator { HapticFeedback.lightImpact(); final navigationProvider = context.read(); navigationProvider.updateCurrentIndex(4); - + await Navigator.of(context).pushNamed(AppRoutes.settings); } /// 카테고리 관리 화면으로 네비게이션 static Future toCategoryManagement(BuildContext context) async { HapticFeedback.lightImpact(); - + await Navigator.of(context).push( SlidePageRoute( page: const CategoryManagementScreen(), @@ -101,20 +103,20 @@ class AppNavigator { static Future handleBackButton(BuildContext context) async { final navigator = Navigator.of(context); final navigationProvider = context.read(); - + // 네비게이션 스택이 있으면 팝 if (navigator.canPop()) { HapticFeedback.lightImpact(); - + // NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원 if (navigationProvider.canPop()) { navigationProvider.pop(); } - + navigator.pop(); return false; } - + // 앱 종료 확인 final shouldExit = await showDialog( context: context, @@ -133,7 +135,7 @@ class AppNavigator { ], ), ); - + return shouldExit ?? false; } @@ -141,17 +143,17 @@ class AppNavigator { static void handleFloatingNavTap(BuildContext context, int index) { final navigationProvider = context.read(); final currentIndex = navigationProvider.currentIndex; - + // 같은 탭을 다시 탭하면 아무 동작 안 함 if (currentIndex == index) { return; } - + // 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기 if (Navigator.of(context).canPop()) { Navigator.of(context).popUntil((route) => route.isFirst); } - + // 선택된 인덱스에 따라 네비게이션 switch (index) { case 0: // 홈 @@ -196,6 +198,7 @@ class AppNavigationObserver extends NavigatorObserver { @override void didReplace({Route? newRoute, Route? oldRoute}) { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); - debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); + debugPrint( + 'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); } -} \ No newline at end of file +} diff --git a/lib/widgets/category_header_widget.dart b/lib/widgets/category_header_widget.dart index 85fd35c..dddba5e 100644 --- a/lib/widgets/category_header_widget.dart +++ b/lib/widgets/category_header_widget.dart @@ -66,13 +66,14 @@ class CategoryHeaderWidget extends StatelessWidget { /// 통화별 합계를 표시하는 문자열을 생성합니다. String _buildCostDisplay(BuildContext context) { final parts = []; - + // 개수는 항상 표시 - parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); - + parts + .add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); + // 통화 부분을 별도로 처리 final currencyParts = []; - + // 달러가 있는 경우 if (totalCostUSD > 0) { final formatter = NumberFormat.currency( @@ -82,7 +83,7 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostUSD)); } - + // 원화가 있는 경우 if (totalCostKRW > 0) { final formatter = NumberFormat.currency( @@ -92,7 +93,7 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostKRW)); } - + // 엔화가 있는 경우 if (totalCostJPY > 0) { final formatter = NumberFormat.currency( @@ -102,7 +103,7 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostJPY)); } - + // 위안화가 있는 경우 if (totalCostCNY > 0) { final formatter = NumberFormat.currency( @@ -112,14 +113,14 @@ class CategoryHeaderWidget extends StatelessWidget { ); currencyParts.add(formatter.format(totalCostCNY)); } - + // 통화가 하나 이상 있는 경우 if (currencyParts.isNotEmpty) { // 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로 final currencyDisplay = currencyParts.join(' + '); parts.add(currencyDisplay); } - + return parts.join(' · '); } } diff --git a/lib/widgets/common/buttons/danger_button.dart b/lib/widgets/common/buttons/danger_button.dart deleted file mode 100644 index 53a1387..0000000 --- a/lib/widgets/common/buttons/danger_button.dart +++ /dev/null @@ -1,174 +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; - } -} \ No newline at end of file diff --git a/lib/widgets/common/buttons/primary_button.dart b/lib/widgets/common/buttons/primary_button.dart index 9dd3434..fa748d8 100644 --- a/lib/widgets/common/buttons/primary_button.dart +++ b/lib/widgets/common/buttons/primary_button.dart @@ -43,8 +43,10 @@ class _PrimaryButtonState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor; - final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite; + final effectiveBackgroundColor = + widget.backgroundColor ?? theme.primaryColor; + final effectiveForegroundColor = + widget.foregroundColor ?? AppColors.pureWhite; Widget button = AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -64,7 +66,8 @@ class _PrimaryButtonState extends State { padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), elevation: widget.enableHoverEffect && _isHovered ? 2 : 0, shadowColor: Colors.black.withValues(alpha: 0.08), - disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6), + disabledBackgroundColor: + effectiveBackgroundColor.withValues(alpha: 0.6), ), child: widget.isLoading ? SizedBox( @@ -110,4 +113,4 @@ class _PrimaryButtonState extends State { return button; } -} \ No newline at end of file +} diff --git a/lib/widgets/common/buttons/secondary_button.dart b/lib/widgets/common/buttons/secondary_button.dart index 30a0258..3f31271 100644 --- a/lib/widgets/common/buttons/secondary_button.dart +++ b/lib/widgets/common/buttons/secondary_button.dart @@ -42,7 +42,6 @@ class _SecondaryButtonState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; @@ -61,18 +60,18 @@ class _SecondaryButtonState extends State { borderRadius: BorderRadius.circular(widget.borderRadius), ), side: BorderSide( - color: _isHovered + color: _isHovered ? effectiveBorderColor.withValues(alpha: 0.4) : effectiveBorderColor, width: widget.borderWidth, ), - padding: widget.padding ?? const EdgeInsets.symmetric( - vertical: 12, - horizontal: 24, - ), - backgroundColor: _isHovered - ? AppColors.glassBackground - : Colors.transparent, + padding: widget.padding ?? + const EdgeInsets.symmetric( + vertical: 12, + horizontal: 24, + ), + backgroundColor: + _isHovered ? AppColors.glassBackground : Colors.transparent, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -146,7 +145,7 @@ class _TextLinkButtonState extends State { Widget button = AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( - color: _isHovered + color: _isHovered ? theme.colorScheme.onSurface.withValues(alpha: 0.05) : Colors.transparent, borderRadius: BorderRadius.circular(8), @@ -179,9 +178,8 @@ class _TextLinkButtonState extends State { fontSize: widget.fontSize, fontWeight: FontWeight.w500, color: effectiveColor, - decoration: _isHovered - ? TextDecoration.underline - : TextDecoration.none, + decoration: + _isHovered ? TextDecoration.underline : TextDecoration.none, ), ), ], @@ -199,4 +197,4 @@ class _TextLinkButtonState extends State { return button; } -} \ No newline at end of file +} diff --git a/lib/widgets/common/cards/section_card.dart b/lib/widgets/common/cards/section_card.dart deleted file mode 100644 index 3b83c18..0000000 --- a/lib/widgets/common/cards/section_card.dart +++ /dev/null @@ -1,229 +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, - ), - ), - ], - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/common/dialogs/confirmation_dialog.dart b/lib/widgets/common/dialogs/confirmation_dialog.dart index 5495b5a..8b819dc 100644 --- a/lib/widgets/common/dialogs/confirmation_dialog.dart +++ b/lib/widgets/common/dialogs/confirmation_dialog.dart @@ -53,7 +53,8 @@ class ConfirmationDialog extends StatelessWidget { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1), + color: + (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -350,4 +351,4 @@ class ErrorDialog extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/dialogs/loading_overlay.dart b/lib/widgets/common/dialogs/loading_overlay.dart deleted file mode 100644 index cafa096..0000000 --- a/lib/widgets/common/dialogs/loading_overlay.dart +++ /dev/null @@ -1,238 +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) => WillPopScope( - onWillPop: () async => 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, -} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/base_text_field.dart b/lib/widgets/common/form_fields/base_text_field.dart index 915b79a..63d685a 100644 --- a/lib/widgets/common/form_fields/base_text_field.dart +++ b/lib/widgets/common/form_fields/base_text_field.dart @@ -59,14 +59,14 @@ class BaseTextField extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (label != null) ...[ Text( label!, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textSecondary, @@ -90,13 +90,14 @@ class BaseTextField extends StatelessWidget { minLines: minLines, readOnly: readOnly, cursorColor: cursorColor ?? theme.primaryColor, - style: style ?? TextStyle( - fontSize: 16, - color: AppColors.textPrimary, - ), + style: style ?? + const TextStyle( + fontSize: 16, + color: AppColors.textPrimary, + ), decoration: InputDecoration( hintText: hintText, - hintStyle: TextStyle( + hintStyle: const TextStyle( color: AppColors.textMuted, ), prefixIcon: prefixIcon, @@ -146,4 +147,4 @@ class BaseTextField extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/billing_cycle_selector.dart b/lib/widgets/common/form_fields/billing_cycle_selector.dart index 97315c4..98da23e 100644 --- a/lib/widgets/common/form_fields/billing_cycle_selector.dart +++ b/lib/widgets/common/form_fields/billing_cycle_selector.dart @@ -24,7 +24,7 @@ class BillingCycleSelector extends StatelessWidget { Widget build(BuildContext context) { final localization = AppLocalizations.of(context); // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 - final cycles = isGlassmorphism + final cycles = isGlassmorphism ? [ localization.billingCycleMonthly, localization.billingCycleQuarterly, @@ -37,7 +37,7 @@ class BillingCycleSelector extends StatelessWidget { localization.billingCycleHalfYearly, localization.yearly, ]; - + return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( @@ -76,7 +76,7 @@ class BillingCycleSelector extends StatelessWidget { Color _getBackgroundColor(bool isSelected) { if (!isSelected) { - return isGlassmorphism + return isGlassmorphism ? AppColors.backgroundColor : Colors.grey.withValues(alpha: 0.1); } @@ -84,11 +84,11 @@ class BillingCycleSelector extends StatelessWidget { if (baseColor != null) { return baseColor!; } - + if (gradientColors != null && gradientColors!.isNotEmpty) { return gradientColors![0]; } - + return const Color(0xFF3B82F6); } @@ -106,8 +106,6 @@ class BillingCycleSelector extends StatelessWidget { if (isSelected) { return Colors.white; } - return isGlassmorphism - ? AppColors.darkNavy - : Colors.grey[700]!; + return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!; } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/category_selector.dart b/lib/widgets/common/form_fields/category_selector.dart index 949bc52..e6904c6 100644 --- a/lib/widgets/common/form_fields/category_selector.dart +++ b/lib/widgets/common/form_fields/category_selector.dart @@ -55,7 +55,8 @@ class CategorySelector extends StatelessWidget { Consumer( builder: (context, categoryProvider, child) { return Text( - categoryProvider.getLocalizedCategoryName(context, category.name), + categoryProvider.getLocalizedCategoryName( + context, category.name), style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, @@ -101,7 +102,7 @@ class CategorySelector extends StatelessWidget { Color _getBackgroundColor(bool isSelected) { if (!isSelected) { - return isGlassmorphism + return isGlassmorphism ? AppColors.backgroundColor : Colors.grey.withValues(alpha: 0.1); } @@ -109,11 +110,11 @@ class CategorySelector extends StatelessWidget { if (baseColor != null) { return baseColor!; } - + if (gradientColors != null && gradientColors!.isNotEmpty) { return gradientColors![0]; } - + return const Color(0xFF3B82F6); } @@ -131,8 +132,6 @@ class CategorySelector extends StatelessWidget { if (isSelected) { return Colors.white; } - return isGlassmorphism - ? AppColors.darkNavy - : Colors.grey[700]!; + return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!; } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/currency_input_field.dart b/lib/widgets/common/form_fields/currency_input_field.dart index ee407e5..b484e14 100644 --- a/lib/widgets/common/form_fields/currency_input_field.dart +++ b/lib/widgets/common/form_fields/currency_input_field.dart @@ -45,7 +45,7 @@ class _CurrencyInputFieldState extends State { super.initState(); _focusNode = widget.focusNode ?? FocusNode(); _focusNode.addListener(_onFocusChanged); - + // 초기값이 있으면 포맷팅 적용 if (widget.controller.text.isNotEmpty) { final value = double.tryParse(widget.controller.text.replaceAll(',', '')); @@ -105,7 +105,11 @@ class _CurrencyInputFieldState extends State { } double? _parseValue(String text) { - final cleanText = text.replaceAll(',', '').replaceAll('₩', '').replaceAll('\$', '').trim(); + final cleanText = text + .replaceAll(',', '') + .replaceAll('₩', '') + .replaceAll('\$', '') + .trim(); return double.tryParse(cleanText); } @@ -128,16 +132,13 @@ class _CurrencyInputFieldState extends State { keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow( - widget.currency == 'KRW' - ? RegExp(r'[0-9]') - : RegExp(r'[0-9.]') - ), + widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')), if (widget.currency == 'USD') // USD의 경우 소수점 이하 2자리까지만 허용 TextInputFormatter.withFunction((oldValue, newValue) { final text = newValue.text; if (text.isEmpty) return newValue; - + final parts = text.split('.'); if (parts.length > 2) { // 소수점이 2개 이상인 경우 거부 @@ -157,16 +158,17 @@ class _CurrencyInputFieldState extends State { final parsedValue = _parseValue(value); widget.onChanged?.call(parsedValue); }, - validator: widget.validator ?? (value) { - if (value == null || value.isEmpty) { - return AppLocalizations.of(context).amountRequired; - } - final parsedValue = _parseValue(value); - if (parsedValue == null || parsedValue <= 0) { - return AppLocalizations.of(context).invalidAmount; - } - return null; - }, + validator: widget.validator ?? + (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context).amountRequired; + } + final parsedValue = _parseValue(value); + if (parsedValue == null || parsedValue <= 0) { + return AppLocalizations.of(context).invalidAmount; + } + return null; + }, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/currency_selector.dart b/lib/widgets/common/form_fields/currency_selector.dart index ec9dd70..84b0f01 100644 --- a/lib/widgets/common/form_fields/currency_selector.dart +++ b/lib/widgets/common/form_fields/currency_selector.dart @@ -86,7 +86,7 @@ class _CurrencyOption extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - + return Expanded( child: InkWell( onTap: onTap, @@ -131,11 +131,9 @@ class _CurrencyOption extends StatelessWidget { Color _getBackgroundColor(ThemeData theme) { if (isSelected) { - return isGlassmorphism - ? theme.primaryColor - : const Color(0xFF3B82F6); + return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6); } - return isGlassmorphism + return isGlassmorphism ? AppColors.surfaceColorAlt : Colors.grey.withValues(alpha: 0.1); } @@ -154,8 +152,6 @@ class _CurrencyOption extends StatelessWidget { if (isSelected) { return Colors.white; } - return isGlassmorphism - ? AppColors.navyGray - : Colors.grey[600]!; + return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!; } -} \ No newline at end of file +} diff --git a/lib/widgets/common/form_fields/date_picker_field.dart b/lib/widgets/common/form_fields/date_picker_field.dart index 61f8886..b5ee04d 100644 --- a/lib/widgets/common/form_fields/date_picker_field.dart +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -42,13 +42,13 @@ class DatePickerField extends StatelessWidget { final localizations = AppLocalizations.of(context); final effectiveDateFormat = dateFormat ?? localizations.dateFormatFull; final locale = Localizations.localeOf(context); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.darkNavy, @@ -57,31 +57,35 @@ class DatePickerField extends StatelessWidget { const SizedBox(height: 8), InkWell( focusNode: focusNode, - onTap: enabled ? () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: selectedDate, - firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)), - lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: effectivePrimaryColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - - if (picked != null && picked != selectedDate) { - onDateSelected(picked); - } - } : null, + onTap: enabled + ? () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: firstDate ?? + DateTime.now().subtract(const Duration(days: 365 * 10)), + lastDate: lastDate ?? + DateTime.now().add(const Duration(days: 365 * 10)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: effectivePrimaryColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null && picked != selectedDate) { + onDateSelected(picked); + } + } + : null, borderRadius: BorderRadius.circular(16), child: Container( padding: contentPadding ?? const EdgeInsets.all(16), @@ -97,21 +101,19 @@ class DatePickerField extends StatelessWidget { children: [ Expanded( child: Text( - DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate), + DateFormat(effectiveDateFormat, locale.toString()) + .format(selectedDate), style: TextStyle( fontSize: 16, - color: enabled - ? AppColors.textPrimary - : AppColors.textMuted, + color: + enabled ? AppColors.textPrimary : AppColors.textMuted, ), ), ), Icon( Icons.calendar_today, size: 20, - color: enabled - ? AppColors.navyGray - : AppColors.textMuted, + color: enabled ? AppColors.navyGray : AppColors.textMuted, ), ], ), @@ -158,7 +160,8 @@ class DateRangePickerField extends StatelessWidget { primaryColor: primaryColor, onDateSelected: onStartDateSelected, firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)), + lastDate: + endDate ?? DateTime.now().add(const Duration(days: 365 * 2)), ), ), const SizedBox(width: 12), @@ -203,31 +206,33 @@ class _DateRangeItem extends StatelessWidget { final effectivePrimaryColor = primaryColor ?? theme.primaryColor; return InkWell( - onTap: enabled ? () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: date ?? DateTime.now(), - firstDate: firstDate, - lastDate: lastDate, - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: effectivePrimaryColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - - if (picked != null) { - onDateSelected(picked); - } - } : null, + onTap: enabled + ? () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: date ?? DateTime.now(), + firstDate: firstDate, + lastDate: lastDate, + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: effectivePrimaryColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + onDateSelected(picked); + } + } + : null, borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.all(16), @@ -244,7 +249,7 @@ class _DateRangeItem extends StatelessWidget { children: [ Text( label, - style: TextStyle( + style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), @@ -252,14 +257,14 @@ class _DateRangeItem extends StatelessWidget { const SizedBox(height: 4), Text( date != null - ? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!) + ? DateFormat(AppLocalizations.of(context).dateFormatShort) + .format(date!) : AppLocalizations.of(context).dateSelect, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: date != null - ? AppColors.textPrimary - : AppColors.textMuted, + color: + date != null ? AppColors.textPrimary : AppColors.textMuted, ), ), ], @@ -267,4 +272,4 @@ class _DateRangeItem extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/common/snackbar/app_snackbar.dart b/lib/widgets/common/snackbar/app_snackbar.dart index 9d4c92d..81b47e8 100644 --- a/lib/widgets/common/snackbar/app_snackbar.dart +++ b/lib/widgets/common/snackbar/app_snackbar.dart @@ -200,7 +200,7 @@ class AppSnackBar { width: 24, height: 24, margin: const EdgeInsets.only(right: 12), - child: CircularProgressIndicator( + child: const CircularProgressIndicator( strokeWidth: 2.5, color: AppColors.pureWhite, ), @@ -269,4 +269,4 @@ class AppSnackBar { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_action_buttons.dart b/lib/widgets/detail/detail_action_buttons.dart index 5998512..d4a318b 100644 --- a/lib/widgets/detail/detail_action_buttons.dart +++ b/lib/widgets/detail/detail_action_buttons.dart @@ -44,4 +44,4 @@ class DetailActionButtons extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart index b51f3c4..65e360b 100644 --- a/lib/widgets/detail/detail_event_section.dart +++ b/lib/widgets/detail/detail_event_section.dart @@ -27,172 +27,177 @@ class DetailEventSection extends StatelessWidget { final baseColor = controller.getCardColor(); return FadeTransition( - opacity: fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.8), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: controller.animationController!, - curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), - )), - child: Container( - decoration: BoxDecoration( - color: AppColors.glassCard, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: const Offset(0, 4), + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Container( + decoration: BoxDecoration( + color: AppColors.glassCard, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 헤더 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 헤더 Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: baseColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.local_offer_rounded, - color: baseColor, - size: 24, - ), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.local_offer_rounded, + color: baseColor, + size: 24, + ), + ), + const SizedBox(width: 12), + Text( + AppLocalizations.of(context).eventPrice, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.darkNavy, + ), + ), + ], ), - const SizedBox(width: 12), - Text( - AppLocalizations.of(context).eventPrice, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.darkNavy, - ), + // 이벤트 활성화 스위치 + Switch.adaptive( + value: controller.isEventActive, + onChanged: (value) { + controller.isEventActive = value; + if (!value) { + // 이벤트 비활성화시 관련 정보 초기화 + controller.eventStartDate = null; + controller.eventEndDate = null; + controller.eventPriceController.clear(); + } + }, + activeColor: baseColor, ), ], ), - // 이벤트 활성화 스위치 - Switch.adaptive( - value: controller.isEventActive, - onChanged: (value) { - controller.isEventActive = value; - if (!value) { - // 이벤트 비활성화시 관련 정보 초기화 - controller.eventStartDate = null; - controller.eventEndDate = null; - controller.eventPriceController.clear(); - } - }, - activeColor: baseColor, - ), + // 이벤트 활성화시 표시될 필드들 + if (controller.isEventActive) ...[ + const SizedBox(height: 20), + // 이벤트 설명 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.infoColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.infoColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.info_outline_rounded, + color: AppColors.infoColor, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of(context).eventPriceHint, + style: const TextStyle( + fontSize: 14, + color: AppColors.darkNavy, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + // 이벤트 기간 + DateRangePickerField( + startDate: controller.eventStartDate, + endDate: controller.eventEndDate, + onStartDateSelected: (date) { + controller.eventStartDate = date; + // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 + if (date != null && controller.eventEndDate == null) { + controller.eventEndDate = + date.add(const Duration(days: 30)); + } + }, + onEndDateSelected: (date) { + controller.eventEndDate = date; + }, + startLabel: AppLocalizations.of(context).startDate, + endLabel: AppLocalizations.of(context).endDate, + primaryColor: baseColor, + ), + const SizedBox(height: 20), + // 이벤트 가격 + CurrencyInputField( + controller: controller.eventPriceController, + currency: controller.currency, + label: AppLocalizations.of(context).eventPrice, + hintText: AppLocalizations.of(context).eventPriceHint, + validator: controller.isEventActive + ? (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context) + .eventPriceRequired; + } + final price = + double.tryParse(value.replaceAll(',', '')); + if (price == null || price <= 0) { + return AppLocalizations.of(context) + .invalidPrice; + } + return null; + } + : null, + ), + const SizedBox(height: 16), + // 할인율 표시 + if (controller.eventPriceController.text.isNotEmpty) + _DiscountBadge( + originalPrice: controller.subscription.monthlyCost, + eventPrice: double.tryParse(controller + .eventPriceController.text + .replaceAll(',', '')) ?? + 0, + currency: controller.currency, + ), + ], ], ), - // 이벤트 활성화시 표시될 필드들 - if (controller.isEventActive) ...[ - const SizedBox(height: 20), - // 이벤트 설명 - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.infoColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.infoColor.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - Icon( - Icons.info_outline_rounded, - color: AppColors.infoColor, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - AppLocalizations.of(context).eventPriceHint, - style: TextStyle( - fontSize: 14, - color: AppColors.darkNavy, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - // 이벤트 기간 - DateRangePickerField( - startDate: controller.eventStartDate, - endDate: controller.eventEndDate, - onStartDateSelected: (date) { - controller.eventStartDate = date; - // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 - if (date != null && controller.eventEndDate == null) { - controller.eventEndDate = date.add(const Duration(days: 30)); - } - }, - onEndDateSelected: (date) { - controller.eventEndDate = date; - }, - startLabel: AppLocalizations.of(context).startDate, - endLabel: AppLocalizations.of(context).endDate, - primaryColor: baseColor, - ), - const SizedBox(height: 20), - // 이벤트 가격 - CurrencyInputField( - controller: controller.eventPriceController, - currency: controller.currency, - label: AppLocalizations.of(context).eventPrice, - hintText: AppLocalizations.of(context).eventPriceHint, - validator: controller.isEventActive - ? (value) { - if (value == null || value.isEmpty) { - return AppLocalizations.of(context).eventPriceRequired; - } - final price = double.tryParse(value.replaceAll(',', '')); - if (price == null || price <= 0) { - return AppLocalizations.of(context).invalidPrice; - } - return null; - } - : null, - ), - const SizedBox(height: 16), - // 할인율 표시 - if (controller.eventPriceController.text.isNotEmpty) - _DiscountBadge( - originalPrice: controller.subscription.monthlyCost, - eventPrice: double.tryParse( - controller.eventPriceController.text.replaceAll(',', '') - ) ?? 0, - currency: controller.currency, - ), - ], - ], + ), ), ), - ), - ), - ); + ); }, ); } @@ -216,7 +221,8 @@ class _DiscountBadge extends StatelessWidget { return const SizedBox.shrink(); } - final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round(); + final discountPercentage = + ((originalPrice - eventPrice) / originalPrice * 100).round(); final discountAmount = originalPrice - eventPrice; return Container( @@ -234,7 +240,9 @@ class _DiscountBadge extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Text( - AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()), + AppLocalizations.of(context) + .discountPercent + .replaceAll('@', discountPercentage.toString()), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -245,8 +253,8 @@ class _DiscountBadge extends StatelessWidget { const SizedBox(width: 12), Text( _getLocalizedDiscountAmount(context, currency, discountAmount), - style: TextStyle( - color: const Color(0xFF15803D), + style: const TextStyle( + color: Color(0xFF15803D), fontSize: 14, fontWeight: FontWeight.w500, ), @@ -256,7 +264,8 @@ class _DiscountBadge extends StatelessWidget { ); } - String _getLocalizedDiscountAmount(BuildContext context, String currency, double amount) { + String _getLocalizedDiscountAmount( + BuildContext context, String currency, double amount) { final loc = AppLocalizations.of(context); switch (currency) { case 'KRW': @@ -264,9 +273,11 @@ class _DiscountBadge extends StatelessWidget { case 'JPY': return loc.discountAmountYen.replaceAll('@', amount.toInt().toString()); case 'CNY': - return loc.discountAmountYuan.replaceAll('@', amount.toStringAsFixed(2)); + return loc.discountAmountYuan + .replaceAll('@', amount.toStringAsFixed(2)); default: // USD - return loc.discountAmountDollar.replaceAll('@', amount.toStringAsFixed(2)); + return loc.discountAmountDollar + .replaceAll('@', amount.toStringAsFixed(2)); } } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart index 25b7015..ffaef54 100644 --- a/lib/widgets/detail/detail_form_section.dart +++ b/lib/widgets/detail/detail_form_section.dart @@ -32,151 +32,111 @@ class DetailFormSection extends StatelessWidget { final baseColor = controller.getCardColor(); return FadeTransition( - opacity: fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.6), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: controller.animationController!, - curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), - )), - child: Container( - decoration: BoxDecoration( - color: AppColors.glassCard, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColors.glassBorder.withValues(alpha: 0.1), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: AppColors.shadowBlack, - blurRadius: 10, - offset: const Offset(0, 4), + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), + )), + child: Container( + decoration: BoxDecoration( + color: AppColors.glassCard, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: const [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // 서비스명 필드 - BaseTextField( - controller: controller.serviceNameController, - focusNode: controller.serviceNameFocus, - label: AppLocalizations.of(context).subscriptionName, - hintText: AppLocalizations.of(context).serviceNameExample, - textInputAction: TextInputAction.next, - onEditingComplete: () { - controller.monthlyCostFocus.requestFocus(); - }, - ), - const SizedBox(height: 20), - - // 월 지출 및 통화 선택 - Row( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - flex: 2, - child: CurrencyInputField( - controller: controller.monthlyCostController, - currency: controller.currency, - label: AppLocalizations.of(context).monthlyExpense, - focusNode: controller.monthlyCostFocus, - textInputAction: TextInputAction.next, - onEditingComplete: () { - controller.billingCycleFocus.requestFocus(); - }, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).currency, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.darkNavy, - ), - ), - const SizedBox(height: 8), - CurrencySelector( - currency: controller.currency, - isGlassmorphism: true, - onChanged: (value) { - controller.currency = value; - // 통화 변경시 금액 포맷 업데이트 - if (value == 'KRW') { - final amount = double.tryParse( - controller.monthlyCostController.text.replaceAll(',', '') - ); - if (amount != null) { - controller.monthlyCostController.text = - amount.toInt().toString(); - } - } - }, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 20), - - // 결제 주기 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).billingCycle, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.darkNavy, - ), - ), - const SizedBox(height: 8), - BillingCycleSelector( - billingCycle: controller.billingCycle, - baseColor: baseColor, - isGlassmorphism: true, - onChanged: (value) { - controller.billingCycle = value; + // 서비스명 필드 + BaseTextField( + controller: controller.serviceNameController, + focusNode: controller.serviceNameFocus, + label: AppLocalizations.of(context).subscriptionName, + hintText: AppLocalizations.of(context).serviceNameExample, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.monthlyCostFocus.requestFocus(); }, ), - ], - ), - const SizedBox(height: 20), + const SizedBox(height: 20), - // 다음 결제일 - DatePickerField( - selectedDate: controller.nextBillingDate, - onDateSelected: (date) { - controller.nextBillingDate = date; - }, - label: AppLocalizations.of(context).nextBillingDate, - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - primaryColor: baseColor, - ), - const SizedBox(height: 20), + // 월 지출 및 통화 선택 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: CurrencyInputField( + controller: controller.monthlyCostController, + currency: controller.currency, + label: AppLocalizations.of(context).monthlyExpense, + focusNode: controller.monthlyCostFocus, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.billingCycleFocus.requestFocus(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).currency, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.darkNavy, + ), + ), + const SizedBox(height: 8), + CurrencySelector( + currency: controller.currency, + isGlassmorphism: true, + onChanged: (value) { + controller.currency = value; + // 통화 변경시 금액 포맷 업데이트 + if (value == 'KRW') { + final amount = double.tryParse(controller + .monthlyCostController.text + .replaceAll(',', '')); + if (amount != null) { + controller.monthlyCostController.text = + amount.toInt().toString(); + } + } + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), - // 카테고리 선택 - Consumer( - builder: (context, categoryProvider, child) { - return Column( + // 결제 주기 + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - AppLocalizations.of(context).category, + AppLocalizations.of(context).billingCycle, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -184,27 +144,67 @@ class DetailFormSection extends StatelessWidget { ), ), const SizedBox(height: 8), - CategorySelector( - categories: categoryProvider.categories, - selectedCategoryId: controller.selectedCategoryId, + BillingCycleSelector( + billingCycle: controller.billingCycle, baseColor: baseColor, isGlassmorphism: true, - onChanged: (categoryId) { - controller.selectedCategoryId = categoryId; + onChanged: (value) { + controller.billingCycle = value; }, ), ], - ); - }, + ), + const SizedBox(height: 20), + + // 다음 결제일 + DatePickerField( + selectedDate: controller.nextBillingDate, + onDateSelected: (date) { + controller.nextBillingDate = date; + }, + label: AppLocalizations.of(context).nextBillingDate, + firstDate: DateTime.now(), + lastDate: + DateTime.now().add(const Duration(days: 365 * 2)), + primaryColor: baseColor, + ), + const SizedBox(height: 20), + + // 카테고리 선택 + Consumer( + builder: (context, categoryProvider, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).category, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.darkNavy, + ), + ), + const SizedBox(height: 8), + CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: controller.selectedCategoryId, + baseColor: baseColor, + isGlassmorphism: true, + onChanged: (categoryId) { + controller.selectedCategoryId = categoryId; + }, + ), + ], + ); + }, + ), + ], ), - ], + ), ), ), - ), - ), - ); + ); }, ); } } - diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index 5a3415e..ebc20c0 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:intl/intl.dart'; import '../../models/subscription_model.dart'; import '../../controllers/detail_screen_controller.dart'; import '../../providers/locale_provider.dart'; @@ -34,191 +33,215 @@ class DetailHeaderSection extends StatelessWidget { final gradient = controller.getGradient(baseColor); return Container( - height: 320, - decoration: BoxDecoration(gradient: gradient), - child: Stack( - children: [ - // 배경 패턴 - Positioned( - top: -50, - right: -50, - child: RotationTransition( - turns: rotateAnimation, - child: Container( - width: 200, - height: 200, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.1), + height: 320, + decoration: BoxDecoration(gradient: gradient), + child: Stack( + children: [ + // 배경 패턴 + Positioned( + top: -50, + right: -50, + child: RotationTransition( + turns: rotateAnimation, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + ), ), ), - ), - ), - Positioned( - bottom: -30, - left: -30, - child: Container( - width: 150, - height: 150, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.08), - ), - ), - ), - // 콘텐츠 - SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 뒤로가기 버튼 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_rounded, - color: Colors.white, - ), - onPressed: () => Navigator.of(context).pop(), - ), - IconButton( - icon: const Icon( - Icons.delete_outline_rounded, - color: Colors.white, - ), - onPressed: controller.deleteSubscription, - ), - ], + Positioned( + bottom: -30, + left: -30, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.08), ), - const Spacer(), - // 서비스 정보 - FadeTransition( - opacity: fadeAnimation, - child: SlideTransition( - position: slideAnimation, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + ), + // 콘텐츠 + SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 뒤로가기 버튼 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // 서비스 아이콘과 이름 - Row( + IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + ), + onPressed: () => Navigator.of(context).pop(), + ), + IconButton( + icon: const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + ), + onPressed: controller.deleteSubscription, + ), + ], + ), + const Spacer(), + // 서비스 정보 + FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: slideAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Hero( - tag: 'icon_${subscription.id}', - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: WebsiteIcon( - url: controller.websiteUrlController.text, - serviceName: controller.serviceNameController.text, - size: 48, - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - controller.displayName ?? controller.serviceNameController.text, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, + // 서비스 아이콘과 이름 + Row( + children: [ + Hero( + tag: 'icon_${subscription.id}', + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( color: Colors.white, - letterSpacing: -0.5, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 2), - blurRadius: 4, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black + .withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 4), ), ], ), - ), - const SizedBox(height: 4), - Text( - AppLocalizations.of(context).billingCyclePayment.replaceAll('@', - _getLocalizedBillingCycle(context, controller.billingCycle)), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.8), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: WebsiteIcon( + url: controller + .websiteUrlController.text, + serviceName: controller + .serviceNameController.text, + size: 48, + ), ), ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + controller.displayName ?? + controller + .serviceNameController.text, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + shadows: [ + Shadow( + color: Colors.black26, + offset: Offset(0, 2), + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of(context) + .billingCyclePayment + .replaceAll( + '@', + _getLocalizedBillingCycle( + context, + controller.billingCycle)), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.white + .withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + // 결제 정보 카드 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + _InfoColumn( + label: AppLocalizations.of(context) + .nextBillingDate, + value: AppLocalizations.of(context) + .formatDate( + controller.nextBillingDate), + ), + FutureBuilder( + future: () async { + final locale = context + .read() + .locale + .languageCode; + final amount = double.tryParse( + controller + .monthlyCostController.text + .replaceAll(',', '')) ?? + 0; + return CurrencyUtil + .formatAmountWithLocale( + amount, + controller.currency, + locale, + ); + }(), + builder: (context, snapshot) { + return _InfoColumn( + label: AppLocalizations.of(context) + .monthlyExpense, + value: snapshot.data ?? '-', + alignment: CrossAxisAlignment.end, + ); + }, + ), ], ), ), ], ), - const SizedBox(height: 20), - // 결제 정보 카드 - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _InfoColumn( - label: AppLocalizations.of(context).nextBillingDate, - value: AppLocalizations.of(context).formatDate(controller.nextBillingDate), - ), - FutureBuilder( - future: () async { - final locale = context.read().locale.languageCode; - final amount = double.tryParse( - controller.monthlyCostController.text.replaceAll(',', '') - ) ?? 0; - return CurrencyUtil.formatAmountWithLocale( - amount, - controller.currency, - locale, - ); - }(), - builder: (context, snapshot) { - return _InfoColumn( - label: AppLocalizations.of(context).monthlyExpense, - value: snapshot.data ?? '-', - alignment: CrossAxisAlignment.end, - ); - }, - ), - ], - ), - ), - ], + ), ), - ), + ], ), - ], + ), ), - ), + ], ), - ], - ), - ); + ); }, ); } + String _getLocalizedBillingCycle(BuildContext context, String cycle) { final loc = AppLocalizations.of(context); switch (cycle.toLowerCase()) { @@ -285,4 +308,4 @@ class _InfoColumn extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart index 183689e..9f9c4fa 100644 --- a/lib/widgets/detail/detail_url_section.dart +++ b/lib/widgets/detail/detail_url_section.dart @@ -41,11 +41,11 @@ class DetailUrlSection extends StatelessWidget { color: AppColors.glassBorder.withValues(alpha: 0.1), width: 1, ), - boxShadow: [ + boxShadow: const [ BoxShadow( color: AppColors.shadowBlack, blurRadius: 10, - offset: const Offset(0, 4), + offset: Offset(0, 4), ), ], ), @@ -81,7 +81,7 @@ class DetailUrlSection extends StatelessWidget { ], ), const SizedBox(height: 20), - + // URL 입력 필드 BaseTextField( controller: controller.websiteUrlController, @@ -89,12 +89,12 @@ class DetailUrlSection extends StatelessWidget { label: AppLocalizations.of(context).websiteUrl, hintText: AppLocalizations.of(context).urlExample, keyboardType: TextInputType.url, - prefixIcon: Icon( + prefixIcon: const Icon( Icons.link_rounded, color: AppColors.navyGray, ), ), - + // 해지 안내 섹션 if (controller.subscription.websiteUrl != null && controller.subscription.websiteUrl!.isNotEmpty) ...[ @@ -114,7 +114,7 @@ class DetailUrlSection extends StatelessWidget { children: [ Row( children: [ - Icon( + const Icon( Icons.info_outline_rounded, color: AppColors.warningColor, size: 20, @@ -122,7 +122,7 @@ class DetailUrlSection extends StatelessWidget { const SizedBox(width: 8), Text( AppLocalizations.of(context).cancelGuide, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.darkNavy, @@ -133,7 +133,7 @@ class DetailUrlSection extends StatelessWidget { const SizedBox(height: 8), Text( AppLocalizations.of(context).cancelServiceGuide, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, fontWeight: FontWeight.w500, @@ -151,7 +151,7 @@ class DetailUrlSection extends StatelessWidget { ), ), ], - + // URL 자동 매칭 정보 if (controller.websiteUrlController.text.isEmpty) ...[ const SizedBox(height: 16), @@ -167,7 +167,7 @@ class DetailUrlSection extends StatelessWidget { ), child: Row( children: [ - Icon( + const Icon( Icons.auto_fix_high_rounded, color: AppColors.infoColor, size: 20, @@ -176,7 +176,7 @@ class DetailUrlSection extends StatelessWidget { Expanded( child: Text( AppLocalizations.of(context).urlAutoMatchInfo, - style: TextStyle( + style: const TextStyle( fontSize: 14, color: AppColors.darkNavy, fontWeight: FontWeight.w500, @@ -194,4 +194,4 @@ class DetailUrlSection extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/dialogs/delete_confirmation_dialog.dart b/lib/widgets/dialogs/delete_confirmation_dialog.dart index 2cf2d83..8670da6 100644 --- a/lib/widgets/dialogs/delete_confirmation_dialog.dart +++ b/lib/widgets/dialogs/delete_confirmation_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:ui'; +import '../../utils/reduce_motion.dart'; import '../../theme/app_colors.dart'; import '../common/buttons/primary_button.dart'; import '../common/buttons/secondary_button.dart'; @@ -27,7 +28,10 @@ class DeleteConfirmationDialog extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(24), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + filter: ImageFilter.blur( + sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4), + sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4), + ), child: Container( decoration: BoxDecoration( color: AppColors.glassCard.withValues(alpha: 0.8), @@ -56,7 +60,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 24), - + // 타이틀 const Text( '구독 삭제', @@ -67,7 +71,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 12), - + // 설명 RichText( textAlign: TextAlign.center, @@ -91,7 +95,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 8), - + // 경고 메시지 Container( padding: const EdgeInsets.symmetric( @@ -127,7 +131,7 @@ class DeleteConfirmationDialog extends StatelessWidget { ), ), const SizedBox(height: 32), - + // 버튼들 Row( children: [ @@ -176,7 +180,7 @@ class DeleteConfirmationDialog extends StatelessWidget { serviceName: serviceName, ), ); - + return result ?? false; } -} \ No newline at end of file +} diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index ddebf72..878c0d3 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -5,6 +5,7 @@ import 'glassmorphism_card.dart'; import 'themed_text.dart'; import '../theme/app_colors.dart'; import '../l10n/app_localizations.dart'; +import '../utils/reduce_motion.dart'; /// 구독이 없을 때 표시되는 빈 화면 위젯 /// @@ -25,101 +26,110 @@ class EmptyStateWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final beginOffset = ReduceMotion.isEnabled(context) + ? const Offset(0, 0.05) + : const Offset(0, 0.2); return FadeTransition( opacity: Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), child: Center( child: SlideTransition( position: Tween( - begin: const Offset(0, 0.2), + begin: beginOffset, end: Offset.zero, ).animate(CurvedAnimation( parent: slideController, curve: Curves.easeOutBack)), - child: GlassmorphismCard( - width: null, - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedBuilder( - animation: rotateController, - builder: (context, child) { - return Transform.rotate( - angle: rotateController.value * 2 * math.pi, - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: AppColors.blueGradient, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.primaryColor.withValues(alpha: 0.3), - spreadRadius: 0, - blurRadius: 16, - offset: const Offset(0, 8), + child: RepaintBoundary( + child: GlassmorphismCard( + width: null, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: rotateController, + builder: (context, child) { + final angleScale = + ReduceMotion.isEnabled(context) ? 0.2 : 1.0; + return Transform.rotate( + angle: + angleScale * rotateController.value * 2 * math.pi, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColors.blueGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - ], + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.primaryColor + .withValues(alpha: 0.3), + spreadRadius: 0, + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: const Icon( + Icons.subscriptions_outlined, + size: 48, + color: AppColors.pureWhite, + ), ), - child: const Icon( - Icons.subscriptions_outlined, - size: 48, + ); + }, + ), + const SizedBox(height: 32), + ThemedText( + AppLocalizations.of(context).noSubscriptions, + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + const SizedBox(height: 8), + ThemedText( + AppLocalizations.of(context).addSubscriptionNow, + fontSize: 16, + opacity: 0.7, + ), + const SizedBox(height: 32), + MouseRegion( + onEnter: (_) => {}, + onExit: (_) => {}, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 4, + backgroundColor: AppColors.primaryColor, + ), + onPressed: () { + HapticFeedback.mediumImpact(); + onAddPressed(); + }, + child: Text( + AppLocalizations.of(context).addSubscription, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, color: AppColors.pureWhite, ), ), - ); - }, - ), - const SizedBox(height: 32), - ThemedText( - AppLocalizations.of(context).noSubscriptions, - fontSize: 22, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - ), - const SizedBox(height: 8), - ThemedText( - AppLocalizations.of(context).addSubscriptionNow, - fontSize: 16, - opacity: 0.7, - ), - const SizedBox(height: 32), - MouseRegion( - onEnter: (_) => {}, - onExit: (_) => {}, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 4, - backgroundColor: AppColors.primaryColor, - ), - onPressed: () { - HapticFeedback.mediumImpact(); - onAddPressed(); - }, - child: Text( - AppLocalizations.of(context).addSubscription, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: AppColors.pureWhite, - ), ), ), - ), - ], + ], + ), ), ), ), 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 ece5110..0000000 --- a/lib/widgets/expandable_fab.dart +++ /dev/null @@ -1,268 +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, - ), - ), - ), - ], - ); - } -} \ No newline at end of file diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 0500b9d..326b369 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -4,6 +4,7 @@ import '../theme/app_colors.dart'; import 'glassmorphism_card.dart'; import '../l10n/app_localizations.dart'; import '../utils/platform_helper.dart'; +import '../utils/reduce_motion.dart'; class FloatingNavigationBar extends StatefulWidget { final int selectedIndex; @@ -30,7 +31,9 @@ class _FloatingNavigationBarState extends State void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 300), + duration: ReduceMotion.platform() + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), vsync: this, ); _animation = CurvedAnimation( @@ -72,9 +75,13 @@ class _FloatingNavigationBarState extends State right: 16, height: 88, child: Transform.translate( - offset: Offset(0, 100 * (1 - _animation.value)), + offset: Offset( + 0, + ReduceMotion.isEnabled(context) + ? 0 + : 100 * (1 - _animation.value)), child: Opacity( - opacity: _animation.value, + opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value, child: Container( margin: const EdgeInsets.all(4), // 그림자 공간 확보 decoration: BoxDecoration( @@ -124,8 +131,11 @@ class _FloatingNavigationBarState extends State _NavigationItem( icon: Icons.settings_rounded, label: AppLocalizations.of(context).settings, - isSelected: PlatformHelper.isIOS ? widget.selectedIndex == 3 : widget.selectedIndex == 4, - onTap: () => _onItemTapped(PlatformHelper.isIOS ? 3 : 4), + isSelected: PlatformHelper.isIOS + ? widget.selectedIndex == 3 + : widget.selectedIndex == 4, + onTap: () => + _onItemTapped(PlatformHelper.isIOS ? 3 : 4), ), ], ), @@ -217,12 +227,14 @@ class _AddButtonState extends State<_AddButton> void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 150), + duration: ReduceMotion.platform() + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 150), vsync: this, ); _scaleAnimation = Tween( begin: 1.0, - end: 0.9, + end: ReduceMotion.platform() ? 1.0 : 0.9, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeInOut, diff --git a/lib/widgets/glassmorphic_app_bar.dart b/lib/widgets/glassmorphic_app_bar.dart deleted file mode 100644 index 86f1aa6..0000000 --- a/lib/widgets/glassmorphic_app_bar.dart +++ /dev/null @@ -1,305 +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!, - ], - ), - ); - }, - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart index a46077b..f0c2ae3 100644 --- a/lib/widgets/glassmorphic_scaffold.dart +++ b/lib/widgets/glassmorphic_scaffold.dart @@ -63,12 +63,12 @@ class _GlassmorphicScaffoldState extends State duration: const Duration(seconds: 20), vsync: this, )..repeat(); - + _waveController = AnimationController( duration: const Duration(seconds: 10), vsync: this, )..repeat(); - + if (widget.useFloatingNavBar) { _scrollController = ScrollController(); _setupScrollListener(); @@ -78,13 +78,16 @@ class _GlassmorphicScaffoldState extends State void _setupScrollListener() { _scrollController?.addListener(() { final currentScroll = _scrollController!.position.pixels; - + // 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김 - if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) { + if (currentScroll > 50 && + _scrollController!.position.userScrollDirection == + ScrollDirection.reverse) { if (_isFloatingNavBarVisible) { setState(() => _isFloatingNavBarVisible = false); } - } else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) { + } else if (_scrollController!.position.userScrollDirection == + ScrollDirection.forward) { if (!_isFloatingNavBarVisible) { setState(() => _isFloatingNavBarVisible = true); } @@ -104,7 +107,7 @@ class _GlassmorphicScaffoldState extends State if (widget.backgroundGradient != null) { return widget.backgroundGradient!; } - + // 디폴트 그라디언트 return AppColors.mainGradient; } @@ -112,18 +115,18 @@ class _GlassmorphicScaffoldState extends State @override Widget build(BuildContext context) { final backgroundGradient = _getBackgroundGradient(); - + return Stack( children: [ // 배경 그라디언트 _buildBackground(backgroundGradient), - + // 파티클 효과 (선택적) if (widget.enableParticles) _buildParticles(), - + // 웨이브 애니메이션 (선택적) if (widget.enableWaveAnimation) _buildWaveAnimation(), - + // 메인 스캐폴드 Scaffold( backgroundColor: widget.backgroundColor ?? Colors.transparent, @@ -138,7 +141,7 @@ class _GlassmorphicScaffoldState extends State drawer: widget.drawer, endDrawer: widget.endDrawer, ), - + // 플로팅 네비게이션 바 (선택적) if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null) FloatingNavigationBar( @@ -159,7 +162,9 @@ class _GlassmorphicScaffoldState extends State gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: gradientColors.map((color) => color.withOpacity(0.3)).toList(), + colors: gradientColors + .map((color) => color.withValues(alpha: 0.3)) + .toList(), ), ), ), @@ -172,10 +177,13 @@ class _GlassmorphicScaffoldState extends State child: AnimatedBuilder( animation: _particleController, builder: (context, child) { + final media = MediaQuery.maybeOf(context); + final reduce = media?.disableAnimations ?? false; + final count = reduce ? 10 : 30; return CustomPaint( painter: ParticlePainter( animation: _particleController, - particleCount: 30, + particleCount: count, ), ); }, @@ -233,11 +241,11 @@ class ParticlePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint()..style = PaintingStyle.fill; - + for (final particle in particles) { final progress = animation.value; final y = (particle.y + progress * particle.speed) % 1.0; - + paint.color = AppColors.pureWhite.withValues(alpha: particle.opacity); canvas.drawCircle( Offset(particle.x * size.width, y * size.height), @@ -266,21 +274,23 @@ class WavePainter extends CustomPainter { final paint = Paint() ..color = waveColor ..style = PaintingStyle.fill; - + final path = Path(); final progress = animation.value; - + path.moveTo(0, size.height); - + for (double x = 0; x <= size.width; x++) { - final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 + - size.height * 0.5; + final y = + math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * + 20 + + size.height * 0.5; path.lineTo(x, y); } - + path.lineTo(size.width, size.height); path.close(); - + canvas.drawPath(path, paint); } @@ -303,4 +313,4 @@ class Particle { required this.speed, required this.opacity, }); -} \ No newline at end of file +} diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 8b2be6c..0b33e29 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import '../utils/logger.dart'; import 'dart:ui'; import '../theme/app_colors.dart'; +import '../utils/reduce_motion.dart'; import 'themed_text.dart'; class GlassmorphismCard extends StatelessWidget { @@ -38,7 +40,7 @@ class GlassmorphismCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - + return Container( width: width, height: height, @@ -51,33 +53,43 @@ class GlassmorphismCard extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + filter: ImageFilter.blur( + sigmaX: ReduceMotion.scale(context, + normal: blur, reduced: blur * 0.4), + sigmaY: ReduceMotion.scale(context, + normal: blur, reduced: blur * 0.4), + ), child: Container( padding: padding, decoration: BoxDecoration( color: backgroundColor ?? AppColors.glassCard, - gradient: gradient ?? LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: isDarkMode - ? AppColors.glassGradientDark - : AppColors.glassGradient, - ), + gradient: gradient ?? + LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? AppColors.glassGradientDark + : AppColors.glassGradient, + ), borderRadius: BorderRadius.circular(borderRadius), - border: border ?? Border.all( - color: isDarkMode - ? AppColors.primaryColor.withValues(alpha: 0.3) - : AppColors.glassBorder, - width: 1, - ), - boxShadow: boxShadow ?? [ - BoxShadow( - color: AppColors.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08) - blurRadius: 20, - spreadRadius: -5, - offset: const Offset(0, 10), - ), - ], + border: border ?? + Border.all( + color: isDarkMode + ? AppColors.primaryColor.withValues(alpha: 0.3) + : AppColors.glassBorder, + width: 1, + ), + boxShadow: boxShadow ?? + [ + BoxShadow( + color: AppColors + .shadowBlack, // color.md: rgba(0,0,0,0.08) + blurRadius: ReduceMotion.scale(context, + normal: 20, reduced: 10), + spreadRadius: -5, + offset: const Offset(0, 10), + ), + ], ), child: GlassmorphicIndicator( child: child, @@ -119,10 +131,11 @@ class AnimatedGlassmorphismCard extends StatefulWidget { }); @override - State createState() => _AnimatedGlassmorphismCardState(); + State createState() => + _AnimatedGlassmorphismCardState(); } -class _AnimatedGlassmorphismCardState extends State +class _AnimatedGlassmorphismCardState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _scaleAnimation; @@ -135,7 +148,7 @@ class _AnimatedGlassmorphismCardState extends State duration: widget.animationDuration, vsync: this, ); - + _scaleAnimation = Tween( begin: 1.0, end: 0.98, @@ -143,7 +156,7 @@ class _AnimatedGlassmorphismCardState extends State parent: _controller, curve: Curves.easeInOut, )); - + _blurAnimation = Tween( begin: widget.blur, end: widget.blur * 1.5, @@ -187,7 +200,7 @@ class _AnimatedGlassmorphismCardState extends State child: widget.child, ); } - + return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: _handleTapDown, @@ -195,7 +208,7 @@ class _AnimatedGlassmorphismCardState extends State _handleTapUp(details); // onTap 콜백 실행 if (widget.onTap != null) { - print('[AnimatedGlassmorphismCard] onTap 콜백 실행'); + Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행'); widget.onTap!(); } }, @@ -203,15 +216,18 @@ class _AnimatedGlassmorphismCardState extends State child: AnimatedBuilder( animation: _controller, builder: (context, child) { + final scaleValue = ReduceMotion.scale(context, + normal: _scaleAnimation.value, reduced: 1.0); return Transform.scale( - scale: _scaleAnimation.value, + scale: scaleValue, child: GlassmorphismCard( padding: widget.padding, margin: widget.margin, width: widget.width, height: widget.height, borderRadius: widget.borderRadius, - blur: _blurAnimation.value, + blur: ReduceMotion.scale(context, + normal: _blurAnimation.value, reduced: widget.blur), opacity: widget.opacity, onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음 child: widget.child, @@ -221,4 +237,4 @@ class _AnimatedGlassmorphismCardState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index c3dd6bf..1ed037a 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -52,8 +52,10 @@ class HomeContent extends StatelessWidget { } // 카테고리별 구독 구분 - final categoryProvider = Provider.of(context, listen: false); - final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions( + final categoryProvider = + Provider.of(context, listen: false); + final categorizedSubscriptions = + SubscriptionCategoryHelper.categorizeSubscriptions( provider.subscriptions, categoryProvider, context, @@ -107,8 +109,8 @@ class HomeContent extends StatelessWidget { child: Text( AppLocalizations.of(context).mySubscriptions, style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.darkNavy, - ), + color: AppColors.darkNavy, + ), ), ), SlideTransition( @@ -120,7 +122,8 @@ class HomeContent extends StatelessWidget { child: Row( children: [ Text( - AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length), + AppLocalizations.of(context) + .subscriptionCount(provider.subscriptions.length), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -153,4 +156,4 @@ class HomeContent extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index d27c104..b35822a 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -33,7 +33,7 @@ class MainScreenSummaryCard extends StatelessWidget { final locale = context.watch().locale.languageCode; final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale); final currencySymbol = CurrencyUtil.getCurrencySymbol(defaultCurrency); - + final int totalSubscriptions = provider.subscriptions.length; final int activeEvents = provider.activeEventSubscriptions.length; @@ -42,279 +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: 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; - - 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, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const CircularProgressIndicator(); + } + final monthlyCost = snapshot.data!; + final decimals = (defaultCurrency == 'KRW' || + defaultCurrency == 'JPY') + ? 0 + : 2; + + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + NumberFormat.currency( + locale: defaultCurrency == 'KRW' + ? 'ko_KR' + : defaultCurrency == 'JPY' + ? 'ja_JP' + : defaultCurrency == 'CNY' + ? 'zh_CN' + : 'en_US', + symbol: '', + decimalDigits: decimals, + ).format(monthlyCost), + style: const TextStyle( + color: AppColors.darkNavy, + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: -1, + ), ), - ), - const SizedBox(width: 4), - Text( - currencySymbol, - style: const TextStyle( - color: AppColors.darkNavy, - fontSize: 16, - fontWeight: FontWeight.w500, + const SizedBox(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, + 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), ), - child: const Icon( - Icons.local_offer_rounded, - size: 14, - color: AppColors - .primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘 + const SizedBox(width: 16), + _buildInfoBox( + context, + title: AppLocalizations.of(context) + .totalSubscriptionServices, + value: + '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}', ), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).eventDiscountActive, - style: 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, - ), - ), - ], - ); - }, - ), + ], + ); + }, + ), + // 이벤트 절약액 표시 + 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, + ), + ), + ], + ); + }, + ), + ], + ), + ], + ), ), - ), + ], ], - ], + ), ), - ), - ], + ], + ), ), ), ), @@ -337,7 +379,7 @@ class MainScreenSummaryCard extends StatelessWidget { children: [ Text( title, - style: TextStyle( + style: const TextStyle( color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 fontSize: 12, fontWeight: FontWeight.w500, diff --git a/lib/widgets/native_ad_widget.dart b/lib/widgets/native_ad_widget.dart index d40fd40..a1bdbd9 100644 --- a/lib/widgets/native_ad_widget.dart +++ b/lib/widgets/native_ad_widget.dart @@ -44,7 +44,7 @@ class _NativeAdWidgetState extends State { } _nativeAd = NativeAd( - adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요 + adUnitId: _testAdUnitId(), // 실제 광고 단위 ID factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함 request: const AdRequest(), listener: NativeAdListener( @@ -63,15 +63,15 @@ class _NativeAdWidgetState extends State { )..load(); } - /// 테스트 광고 단위 ID 반환 함수 + /// 광고 단위 ID 반환 함수 /// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용 String _testAdUnitId() { if (Platform.isAndroid) { - // Android 테스트 네이티브 광고 ID - return 'ca-app-pub-3940256099942544/2247696110'; + // Android 네이티브 광고 ID + return 'ca-app-pub-6691216385521068/4512709971'; } else if (Platform.isIOS) { - // iOS 테스트 네이티브 광고 ID - return 'ca-app-pub-3940256099942544/3986624511'; + // iOS 네이티브 광고 ID + return 'ca-app-pub-6691216385521068/4512709971'; } return ''; } diff --git a/lib/widgets/skeleton_loading.dart b/lib/widgets/skeleton_loading.dart deleted file mode 100644 index f3ff7bc..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), - ), - ), - ], - ); - } -} \ No newline at end of file diff --git a/lib/widgets/sms_scan/scan_initial_widget.dart b/lib/widgets/sms_scan/scan_initial_widget.dart index bf2059b..86da4b5 100644 --- a/lib/widgets/sms_scan/scan_initial_widget.dart +++ b/lib/widgets/sms_scan/scan_initial_widget.dart @@ -67,4 +67,4 @@ class ScanInitialWidget extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart index ad28115..153ecd5 100644 --- a/lib/widgets/sms_scan/scan_loading_widget.dart +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator( + const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), ), const SizedBox(height: 16), @@ -33,4 +33,4 @@ class ScanLoadingWidget extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/sms_scan/scan_progress_widget.dart b/lib/widgets/sms_scan/scan_progress_widget.dart index 7a830fc..e4d72b4 100644 --- a/lib/widgets/sms_scan/scan_progress_widget.dart +++ b/lib/widgets/sms_scan/scan_progress_widget.dart @@ -35,4 +35,4 @@ class ScanProgressWidget extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart index c99e262..d63e028 100644 --- a/lib/widgets/sms_scan/subscription_card_widget.dart +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -43,7 +43,8 @@ class _SubscriptionCardWidgetState extends State { void initState() { super.initState(); // URL 필드 자동 설정 - if (widget.websiteUrlController.text.isEmpty && widget.subscription.websiteUrl != null) { + if (widget.websiteUrlController.text.isEmpty && + widget.subscription.websiteUrl != null) { widget.websiteUrlController.text = widget.subscription.websiteUrl!; } } @@ -110,13 +111,13 @@ class _SubscriptionCardWidgetState extends State { ), ), ), - + // 구분선 Container( height: 1, color: AppColors.navyGray.withValues(alpha: 0.1), ), - + // 클릭 불가능한 액션 영역 Padding( padding: const EdgeInsets.all(16.0), @@ -145,7 +146,7 @@ class _SubscriptionCardWidgetState extends State { forceDark: true, ), const SizedBox(height: 24), - + // 서비스명 ThemedText( AppLocalizations.of(context).serviceName, @@ -256,7 +257,8 @@ class _SubscriptionCardWidgetState extends State { const SizedBox(height: 8), CategorySelector( categories: categoryProvider.categories, - selectedCategoryId: widget.selectedCategoryId ?? widget.subscription.category, + selectedCategoryId: + widget.selectedCategoryId ?? widget.subscription.category, onChanged: widget.onCategoryChanged, baseColor: _getCategoryColor(categoryProvider), isGlassmorphism: true, @@ -304,12 +306,13 @@ class _SubscriptionCardWidgetState extends State { } Color? _getCategoryColor(CategoryProvider categoryProvider) { - final categoryId = widget.selectedCategoryId ?? widget.subscription.category; + final categoryId = + widget.selectedCategoryId ?? widget.subscription.category; if (categoryId == null) return null; - + final category = categoryProvider.getCategoryById(categoryId); if (category == null) return null; - + return CategoryIconMapper.getCategoryColor(category); } -} \ No newline at end of file +} diff --git a/lib/widgets/spring_animation_widget.dart b/lib/widgets/spring_animation_widget.dart deleted file mode 100644 index 9e46fb2..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, - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/staggered_list_animation.dart b/lib/widgets/staggered_list_animation.dart index 28f8683..e1a2071 100644 --- a/lib/widgets/staggered_list_animation.dart +++ b/lib/widgets/staggered_list_animation.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:math' as math; +import '../utils/reduce_motion.dart'; /// 스태거 애니메이션이 적용된 리스트 위젯 class StaggeredListAnimation extends StatefulWidget { @@ -8,7 +9,7 @@ class StaggeredListAnimation extends StatefulWidget { final Duration animationDuration; final Curve curve; final Axis direction; - + const StaggeredListAnimation({ super.key, required this.children, @@ -42,7 +43,7 @@ class _StaggeredListAnimationState extends State duration: widget.animationDuration, vsync: this, ); - + final fadeAnimation = Tween( begin: 0.0, end: 1.0, @@ -50,7 +51,7 @@ class _StaggeredListAnimationState extends State parent: controller, curve: widget.curve, )); - + final slideAnimation = Tween( begin: widget.direction == Axis.vertical ? const Offset(0, 0.3) @@ -60,7 +61,7 @@ class _StaggeredListAnimationState extends State parent: controller, curve: widget.curve, )); - + final scaleAnimation = Tween( begin: 0.8, end: 1.0, @@ -68,7 +69,7 @@ class _StaggeredListAnimationState extends State parent: controller, curve: widget.curve, )); - + _controllers.add(controller); _fadeAnimations.add(fadeAnimation); _slideAnimations.add(slideAnimation); @@ -95,13 +96,14 @@ class _StaggeredListAnimationState extends State @override Widget build(BuildContext context) { + if (ReduceMotion.platform()) { + return widget.direction == Axis.vertical + ? Column(children: widget.children) + : Row(children: widget.children); + } return widget.direction == Axis.vertical - ? Column( - children: _buildAnimatedChildren(), - ) - : Row( - children: _buildAnimatedChildren(), - ); + ? Column(children: _buildAnimatedChildren()) + : Row(children: _buildAnimatedChildren()); } List _buildAnimatedChildren() { @@ -132,7 +134,7 @@ class StaggeredAnimationItem extends StatefulWidget { final Duration delay; final Duration duration; final Curve curve; - + const StaggeredAnimationItem({ super.key, required this.child, @@ -156,11 +158,12 @@ class _StaggeredAnimationItemState extends State @override void initState() { super.initState(); + final reduced = ReduceMotion.platform(); _controller = AnimationController( - duration: widget.duration, + duration: reduced ? Duration.zero : widget.duration, vsync: this, ); - + _fadeAnimation = Tween( begin: 0.0, end: 1.0, @@ -168,15 +171,17 @@ class _StaggeredAnimationItemState extends State parent: _controller, curve: widget.curve, )); - + _slideAnimation = Tween( - begin: const Offset(0, 0.3), + begin: ReduceMotion.platform() + ? const Offset(0, 0.05) + : const Offset(0, 0.3), end: Offset.zero, ).animate(CurvedAnimation( parent: _controller, curve: widget.curve, )); - + _scaleAnimation = Tween( begin: 0.8, end: 1.0, @@ -184,12 +189,12 @@ class _StaggeredAnimationItemState extends State parent: _controller, curve: widget.curve, )); - - // 지연 후 애니메이션 시작 - Future.delayed(widget.delay * widget.index, () { - if (mounted) { - _controller.forward(); - } + + // 지연 후 애니메이션 시작 (모션 축소 시 지연 없음) + final startDelay = + ReduceMotion.platform() ? Duration.zero : widget.delay * widget.index; + Future.delayed(startDelay, () { + if (mounted) _controller.forward(); }); } @@ -201,6 +206,7 @@ class _StaggeredAnimationItemState extends State @override Widget build(BuildContext context) { + if (ReduceMotion.platform()) return widget.child; return AnimatedBuilder( animation: _controller, builder: (context, child) { @@ -224,7 +230,7 @@ class FlipAnimationCard extends StatefulWidget { final Widget front; final Widget back; final Duration duration; - + const FlipAnimationCard({ super.key, required this.front, @@ -249,7 +255,7 @@ class _FlipAnimationCardState extends State duration: widget.duration, vsync: this, ); - + _animation = Tween( begin: 0.0, end: 1.0, @@ -299,4 +305,4 @@ class _FlipAnimationCardState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index 2728d6f..a70b832 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; @@ -41,18 +40,18 @@ class _SubscriptionCardState extends State ); _loadDisplayName(); } - + Future _loadDisplayName() async { if (!mounted) return; - + final localeProvider = context.read(); final locale = localeProvider.locale.languageCode; - + final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( serviceName: widget.subscription.serviceName, locale: locale, ); - + if (mounted) { setState(() { _displayName = displayName; @@ -60,7 +59,6 @@ class _SubscriptionCardState extends State } } - @override void didUpdateWidget(SubscriptionCard oldWidget) { super.didUpdateWidget(oldWidget); @@ -203,7 +201,9 @@ class _SubscriptionCardState extends State daysUntilNext = 7; // 다음 주 같은 요일 } - if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday; + if (daysUntilNext == 0) { + return AppLocalizations.of(context).paymentDueToday; + } return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); } @@ -232,18 +232,18 @@ class _SubscriptionCardState extends State if (widget.subscription.categoryId == null) { return AppColors.blueGradient; } - + final categoryProvider = context.watch(); - final category = categoryProvider.getCategoryById(widget.subscription.categoryId!); - + final category = + categoryProvider.getCategoryById(widget.subscription.categoryId!); + if (category == null) { return AppColors.blueGradient; } - - final categoryColor = Color( - int.parse(category.color.replaceAll('#', '0xFF')) - ); - + + final categoryColor = + Color(int.parse(category.color.replaceAll('#', '0xFF'))); + return [ categoryColor, categoryColor.withValues(alpha: 0.8), @@ -283,12 +283,12 @@ class _SubscriptionCardState extends State Widget build(BuildContext context) { // LocaleProvider를 watch하여 언어 변경시 자동 업데이트 final localeProvider = context.watch(); - + // 언어가 변경되면 displayName 다시 로드 WidgetsBinding.instance.addPostFrameCallback((_) { _loadDisplayName(); }); - + final isNearBilling = _isNearBilling(); return Hero( @@ -300,11 +300,12 @@ class _SubscriptionCardState extends State padding: EdgeInsets.zero, borderRadius: 16, blur: _isHovering ? 15 : 10, - width: double.infinity, // 전체 너비를 차지하도록 설정 - onTap: widget.onTap ?? () async { - print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}'); - await AppNavigator.toDetail(context, widget.subscription); - }, + width: double.infinity, // 전체 너비를 차지하도록 설정 + onTap: widget.onTap ?? + () async { + // ignore: use_build_context_synchronously + await AppNavigator.toDetail(context, widget.subscription); + }, child: Column( children: [ // 그라데이션 상단 바 효과 @@ -330,281 +331,290 @@ class _SubscriptionCardState extends State Padding( padding: const EdgeInsets.all(16), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 서비스 아이콘 + WebsiteIcon( + key: ValueKey( + 'subscription_icon_${widget.subscription.id}'), + url: widget.subscription.websiteUrl, + serviceName: widget.subscription.serviceName, + size: 48, + isHovered: _isHovering, + ), + const SizedBox(width: 16), + + // 서비스 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // 서비스 아이콘 - WebsiteIcon( - key: ValueKey( - 'subscription_icon_${widget.subscription.id}'), - url: widget.subscription.websiteUrl, - serviceName: widget.subscription.serviceName, - size: 48, - isHovered: _isHovering, + // 서비스명 + Flexible( + child: Text( + _displayName ?? + widget.subscription.serviceName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: AppColors + .darkNavy, // color.md 가이드: 메인 텍스트 + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - const SizedBox(width: 16), - // 서비스 정보 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - // 서비스명 - Flexible( - child: Text( - _displayName ?? widget.subscription.serviceName, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 18, - color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - - // 배지들 - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 이벤트 배지 - if (widget.subscription.isCurrentlyInEvent) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFFFF6B6B), - Color(0xFFFF8787), - ], - ), - borderRadius: - BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.local_offer_rounded, - size: 11, - color: AppColors.pureWhite, - ), - const SizedBox(width: 3), - Text( - AppLocalizations.of(context).event, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.pureWhite, - ), - ), - ], - ), - ), - const SizedBox(width: 6), - ], - - // 결제 주기 배지 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, - borderRadius: - BorderRadius.circular(12), - border: Border.all( - color: AppColors.borderColor, - width: 0.5, - ), - ), - child: Text( - AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle), - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - ), - ), - ), + // 배지들 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 이벤트 배지 + if (widget + .subscription.isCurrentlyInEvent) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFFFF6B6B), + Color(0xFFFF8787), ], ), - ], - ), - - const SizedBox(height: 6), - - // 가격 정보 - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - // 가격 표시 (이벤트 가격 반영) - // 가격 표시 (언어별 통화) - FutureBuilder( - future: _getFormattedPrice(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - - if (widget.subscription.isCurrentlyInEvent && snapshot.data!.contains('|')) { - final prices = snapshot.data!.split('|'); - return Row( - children: [ - Text( - prices[0], - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.navyGray, - decoration: TextDecoration.lineThrough, - ), - ), - const SizedBox(width: 8), - Text( - prices[1], - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Color(0xFFFF6B6B), - ), - ), - ], - ); - } else { - return Text( - snapshot.data!, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: widget.subscription.isCurrentlyInEvent - ? const Color(0xFFFF6B6B) - : AppColors.primaryColor, - ), - ); - } - }, - ), - - // 결제 예정일 정보 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: isNearBilling - ? AppColors.warningColor - .withValues(alpha: 0.1) - : AppColors.successColor - .withValues(alpha: 0.1), - borderRadius: - BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isNearBilling - ? Icons - .access_time_filled_rounded - : Icons - .check_circle_rounded, - size: 12, - color: isNearBilling - ? AppColors.warningColor - : AppColors.successColor, - ), - const SizedBox(width: 4), - Text( - _getNextBillingText(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: isNearBilling - ? AppColors.warningColor - : AppColors.successColor, - ), - ), - ], - ), - ), - ], - ), - - // 이벤트 절약액 표시 - if (widget.subscription.isCurrentlyInEvent && - widget.subscription.eventSavings > 0) ...[ - const SizedBox(height: 4), - Row( + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: const Color(0xFFFF6B6B).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.savings_rounded, - size: 14, - color: Color(0xFFFF6B6B), - ), - const SizedBox(width: 4), - // 이벤트 절약액 표시 (언어별 통화) - FutureBuilder( - future: CurrencyUtil.formatEventSavingsWithLocale( - widget.subscription, - localeProvider.locale.languageCode, - ), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox(); - } - return Text( - '${snapshot.data!} ${AppLocalizations.of(context).saving}', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFFFF6B6B), - ), - ); - }, - ), - ], + const Icon( + Icons.local_offer_rounded, + size: 11, + color: AppColors.pureWhite, + ), + const SizedBox(width: 3), + Text( + AppLocalizations.of(context).event, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.pureWhite, ), ), - const SizedBox(width: 8), - // 이벤트 종료일까지 남은 일수 - if (widget.subscription.eventEndDate != null) ...[ - Text( - AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays), - style: const TextStyle( - fontSize: 11, - color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 - ), - ), - ], ], ), - ], + ), + const SizedBox(width: 6), + ], + + // 결제 주기 배지 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.surfaceColorAlt, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.borderColor, + width: 0.5, + ), + ), + child: Text( + AppLocalizations.of(context) + .getBillingCycleName( + widget.subscription.billingCycle), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors + .navyGray, // color.md 가이드: 서브 텍스트 + ), + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 6), + + // 가격 정보 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 가격 표시 (이벤트 가격 반영) + // 가격 표시 (언어별 통화) + FutureBuilder( + future: _getFormattedPrice(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + + if (widget.subscription.isCurrentlyInEvent && + snapshot.data!.contains('|')) { + final prices = snapshot.data!.split('|'); + return Row( + children: [ + Text( + prices[0], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.navyGray, + decoration: + TextDecoration.lineThrough, + ), + ), + const SizedBox(width: 8), + Text( + prices[1], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFFFF6B6B), + ), + ), + ], + ); + } else { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: widget + .subscription.isCurrentlyInEvent + ? const Color(0xFFFF6B6B) + : AppColors.primaryColor, + ), + ); + } + }, + ), + + // 결제 예정일 정보 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: isNearBilling + ? AppColors.warningColor + .withValues(alpha: 0.1) + : AppColors.successColor + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isNearBilling + ? Icons.access_time_filled_rounded + : Icons.check_circle_rounded, + size: 12, + color: isNearBilling + ? AppColors.warningColor + : AppColors.successColor, + ), + const SizedBox(width: 4), + Text( + _getNextBillingText(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isNearBilling + ? AppColors.warningColor + : AppColors.successColor, + ), + ), ], ), ), ], + ), + + // 이벤트 절약액 표시 + if (widget.subscription.isCurrentlyInEvent && + widget.subscription.eventSavings > 0) ...[ + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFFFF6B6B) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.savings_rounded, + size: 14, + color: Color(0xFFFF6B6B), + ), + const SizedBox(width: 4), + // 이벤트 절약액 표시 (언어별 통화) + FutureBuilder( + future: CurrencyUtil + .formatEventSavingsWithLocale( + widget.subscription, + localeProvider.locale.languageCode, + ), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + return Text( + '${snapshot.data!} ${AppLocalizations.of(context).saving}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFFF6B6B), + ), + ); + }, + ), + ], + ), + ), + const SizedBox(width: 8), + // 이벤트 종료일까지 남은 일수 + if (widget.subscription.eventEndDate != + null) ...[ + Text( + AppLocalizations.of(context).daysRemaining( + widget.subscription.eventEndDate! + .difference(DateTime.now()) + .inDays), + style: const TextStyle( + fontSize: 11, + color: AppColors + .navyGray, // color.md 가이드: 서브 텍스트 + ), + ), + ], + ], + ), + ], + ], + ), + ), + ], ), ), ], diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index 65e3bd9..5af84a0 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -12,6 +12,7 @@ import '../services/subscription_url_matcher.dart'; import './dialogs/delete_confirmation_dialog.dart'; import './common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; +import '../utils/logger.dart'; /// 카테고리별로 구독 목록을 표시하는 위젯 class SubscriptionListWidget extends StatelessWidget { @@ -46,12 +47,17 @@ class SubscriptionListWidget extends StatelessWidget { child: Consumer( builder: (context, categoryProvider, child) { return CategoryHeaderWidget( - categoryName: categoryProvider.getLocalizedCategoryName(context, category), + categoryName: categoryProvider.getLocalizedCategoryName( + context, category), subscriptionCount: subscriptions.length, - totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), - totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), - totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'), - totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'), + totalCostUSD: + _calculateTotalByCurrency(subscriptions, 'USD'), + totalCostKRW: + _calculateTotalByCurrency(subscriptions, 'KRW'), + totalCostJPY: + _calculateTotalByCurrency(subscriptions, 'JPY'), + totalCostCNY: + _calculateTotalByCurrency(subscriptions, 'CNY'), ); }, ), @@ -65,6 +71,8 @@ class SubscriptionListWidget extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, padding: const EdgeInsets.symmetric(horizontal: 16), + cacheExtent: 500, + prototypeItem: const SizedBox(height: 156), itemCount: subscriptions.length, itemBuilder: (context, subIndex) { // 각 구독의 지연값 계산 (순차적으로 나타나도록) @@ -92,49 +100,64 @@ class SubscriptionListWidget extends StatelessWidget { child: StaggeredAnimationItem( index: subIndex, delay: const Duration(milliseconds: 50), - child: SwipeableSubscriptionCard( - subscription: subscriptions[subIndex], - onTap: () { - print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); - AppNavigator.toDetail(context, subscriptions[subIndex]); - }, - onDelete: () async { - // 현재 로케일에 맞는 서비스명 가져오기 - final localeProvider = Provider.of( - context, - listen: false, - ); - final locale = localeProvider.locale.languageCode; - final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( - serviceName: subscriptions[subIndex].serviceName, - locale: locale, - ); - - // 삭제 확인 다이얼로그 표시 - final shouldDelete = await DeleteConfirmationDialog.show( - context: context, - serviceName: displayName, - ); - - if (shouldDelete && context.mounted) { - // 사용자가 확인한 경우에만 삭제 진행 - final provider = Provider.of( - context, + child: RepaintBoundary( + child: SwipeableSubscriptionCard( + subscription: subscriptions[subIndex], + keepAlive: true, + onTap: () { + Log.d( + '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); + AppNavigator.toDetail( + context, subscriptions[subIndex]); + }, + onDelete: () async { + // 현재 로케일에 맞는 서비스명 가져오기 + final localeProvider = + Provider.of( + context, listen: false, ); - await provider.deleteSubscription( - subscriptions[subIndex].id, + final locale = + localeProvider.locale.languageCode; + final displayName = + await SubscriptionUrlMatcher + .getServiceDisplayName( + serviceName: + subscriptions[subIndex].serviceName, + locale: locale, ); - - if (context.mounted) { - AppSnackBar.showError( - context: context, - message: AppLocalizations.of(context).subscriptionDeleted(displayName), - icon: Icons.delete_forever_rounded, + + // 삭제 확인 다이얼로그 표시 + if (!context.mounted) return; + final shouldDelete = + await DeleteConfirmationDialog.show( + context: context, + serviceName: displayName, + ); + if (!context.mounted) return; + + if (shouldDelete) { + // 사용자가 확인한 경우에만 삭제 진행 + final provider = + Provider.of( + context, + listen: false, ); + await provider.deleteSubscription( + subscriptions[subIndex].id, + ); + + if (context.mounted) { + AppSnackBar.showError( + context: context, + message: AppLocalizations.of(context) + .subscriptionDeleted(displayName), + icon: Icons.delete_forever_rounded, + ); + } } - } - }, + }, + ), ), ), ), @@ -152,7 +175,8 @@ class SubscriptionListWidget extends StatelessWidget { } /// 특정 통화의 총 합계를 계산합니다. - double _calculateTotalByCurrency(List subscriptions, String currency) { + double _calculateTotalByCurrency( + List subscriptions, String currency) { return subscriptions .where((sub) => sub.currency == currency) .fold(0.0, (sum, sub) => sum + sub.monthlyCost); diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart index f674a1b..ebc10f1 100644 --- a/lib/widgets/swipeable_subscription_card.dart +++ b/lib/widgets/swipeable_subscription_card.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; import '../models/subscription_model.dart'; import '../utils/haptic_feedback_helper.dart'; import 'subscription_card.dart'; +import '../utils/reduce_motion.dart'; class SwipeableSubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; final VoidCallback? onEdit; final Future Function()? onDelete; final VoidCallback? onTap; + final bool keepAlive; const SwipeableSubscriptionCard({ super.key, @@ -16,6 +17,7 @@ class SwipeableSubscriptionCard extends StatefulWidget { this.onEdit, this.onDelete, this.onTap, + this.keepAlive = false, }); @override @@ -24,12 +26,11 @@ class SwipeableSubscriptionCard extends StatefulWidget { } class _SwipeableSubscriptionCardState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { // 상수 정의 static const double _tapTolerance = 20.0; // 탭 허용 범위 static const double _actionThresholdPercent = 0.15; static const double _deleteThresholdPercent = 0.40; - static const int _tapDurationMs = 500; static const double _velocityThreshold = 800.0; // static const double _animationDuration = 300.0; @@ -39,8 +40,7 @@ class _SwipeableSubscriptionCardState extends State // 제스처 추적 Offset? _startPosition; - DateTime? _startTime; - bool _isValidTap = true; + // 제스처 관련 보조 변수(간소화) // 상태 관리 double _currentOffset = 0; @@ -52,7 +52,9 @@ class _SwipeableSubscriptionCardState extends State void initState() { super.initState(); _controller = AnimationController( - duration: const Duration(milliseconds: 300), + duration: ReduceMotion.platform() + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), vsync: this, ); _animation = Tween( @@ -95,8 +97,6 @@ class _SwipeableSubscriptionCardState extends State // 제스처 핸들러 void _handlePanStart(DragStartDetails details) { _startPosition = details.localPosition; - _startTime = DateTime.now(); - _isValidTap = true; _hapticTriggered = false; _controller.stop(); } @@ -104,12 +104,7 @@ class _SwipeableSubscriptionCardState extends State void _handlePanUpdate(DragUpdateDetails details) { final currentPosition = details.localPosition; final delta = currentPosition.dx - _startPosition!.dx; - final distance = (currentPosition - _startPosition!).distance; - - // 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주 - if (distance > _tapTolerance) { - _isValidTap = false; - } + // 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거 // 카드 이동 setState(() { @@ -129,14 +124,7 @@ class _SwipeableSubscriptionCardState extends State } // 헬퍼 메서드 - void _processTap() { - print('[SwipeableSubscriptionCard] _processTap 호출됨'); - if (widget.onTap != null) { - print('[SwipeableSubscriptionCard] onTap 콜백 실행'); - widget.onTap!(); - } - _animateToOffset(0); - } + // 탭 처리는 SubscriptionCard에서 수행 void _processSwipe(double velocity) { final extent = _currentOffset.abs(); @@ -232,10 +220,14 @@ class _SwipeableSubscriptionCardState extends State right: isLeft ? 0 : 24, ), child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: ReduceMotion.platform() + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 200), opacity: showIcon ? 1.0 : 0.0, child: AnimatedScale( - duration: const Duration(milliseconds: 200), + duration: ReduceMotion.platform() + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 200), scale: showIcon ? 1.0 : 0.5, child: Icon( isDeleteThreshold @@ -253,12 +245,14 @@ class _SwipeableSubscriptionCardState extends State return Transform.translate( offset: Offset(_currentOffset, 0), child: Transform.scale( - scale: 1.0 - (_currentOffset.abs() / 2000), + scale: + ReduceMotion.platform() ? 1.0 : 1.0 - (_currentOffset.abs() / 2000), child: Transform.rotate( - angle: _currentOffset / 2000, + angle: ReduceMotion.platform() ? 0.0 : _currentOffset / 2000, child: SubscriptionCard( subscription: widget.subscription, - onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 + onTap: widget + .onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 ), ), ), @@ -267,6 +261,7 @@ class _SwipeableSubscriptionCardState extends State @override Widget build(BuildContext context) { + super.build(context); // 웹과 모바일 모두 동일한 스와이프 기능 제공 return Stack( children: [ @@ -282,4 +277,7 @@ class _SwipeableSubscriptionCardState extends State ], ); } + + @override + bool get wantKeepAlive => widget.keepAlive; } diff --git a/lib/widgets/themed_text.dart b/lib/widgets/themed_text.dart index e46abc7..97319dd 100644 --- a/lib/widgets/themed_text.dart +++ b/lib/widgets/themed_text.dart @@ -35,47 +35,49 @@ class ThemedText extends StatelessWidget { }); /// 배경 밝기에 따른 텍스트 색상 결정 - static Color getContrastColor(BuildContext context, { + static Color getContrastColor( + BuildContext context, { bool forceLight = false, bool forceDark = false, }) { if (forceLight) return AppColors.pureWhite; if (forceDark) return AppColors.darkNavy; - + final brightness = Theme.of(context).brightness; - + // 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용 if (_isGlassmorphicContext(context)) { - return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트 + return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트 } - + // 일반 환경 - return brightness == Brightness.dark - ? AppColors.pureWhite + return brightness == Brightness.dark + ? AppColors.pureWhite : AppColors.darkNavy; } /// 글래스모피즘 컨텍스트인지 확인 static bool _isGlassmorphicContext(BuildContext context) { // 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인 - final glassmorphic = context.findAncestorWidgetOfExactType(); + final glassmorphic = + context.findAncestorWidgetOfExactType(); return glassmorphic != null; } @override Widget build(BuildContext context) { - final textColor = color ?? getContrastColor( - context, - forceLight: forceLight, - forceDark: forceDark, - ); - - final finalColor = opacity != null - ? textColor.withValues(alpha: opacity!) - : textColor; - + final textColor = color ?? + getContrastColor( + context, + forceLight: forceLight, + forceDark: forceDark, + ); + + final finalColor = + opacity != null ? textColor.withValues(alpha: opacity!) : textColor; + final defaultStyle = DefaultTextStyle.of(context).style; - + // 개별 스타일 속성들을 병합 final baseStyle = TextStyle( fontSize: fontSize, @@ -83,9 +85,9 @@ class ThemedText extends StatelessWidget { letterSpacing: letterSpacing, color: finalColor, ); - + final effectiveStyle = defaultStyle.merge(baseStyle).merge(style); - + return Text( text, style: effectiveStyle, @@ -193,7 +195,7 @@ class GlassmorphicIndicator extends InheritedWidget { /// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼 class GlassmorphicTextWrapper extends StatelessWidget { final Widget child; - + const GlassmorphicTextWrapper({ super.key, required this.child, @@ -204,10 +206,10 @@ class GlassmorphicTextWrapper extends StatelessWidget { return GlassmorphicIndicator( child: DefaultTextStyle( style: DefaultTextStyle.of(context).style.copyWith( - color: ThemedText.getContrastColor(context), - ), + color: ThemedText.getContrastColor(context), + ), child: child, ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/website_icon.dart b/lib/widgets/website_icon.dart index 788920f..db8db44 100644 --- a/lib/widgets/website_icon.dart +++ b/lib/widgets/website_icon.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider/path_provider.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart' show kIsWeb; +import '../utils/reduce_motion.dart'; // 파비콘 캐시 관리 클래스 class FaviconCache { @@ -104,8 +105,6 @@ class FaviconCache { // 구글 파비콘 API 서비스 class GoogleFaviconService { - - // 구글 파비콘 API URL 생성 static String getFaviconUrl(String domain, int size) { final directUrl = @@ -137,7 +136,8 @@ class GoogleFaviconService { static String getBase64PlaceholderIcon(String serviceName, Color color) { // 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시) final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?'; - final colorHex = color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2); + final colorHex = + color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2); // 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생) final svgContent = @@ -191,12 +191,15 @@ class _WebsiteIconState extends State // 애니메이션 컨트롤러 초기화 _animationController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 300), + duration: ReduceMotion.platform() + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), ); - _scaleAnimation = Tween(begin: 1.0, end: 1.08).animate( - CurvedAnimation( - parent: _animationController, curve: Curves.easeOutCubic)); + _scaleAnimation = + Tween(begin: 1.0, end: ReduceMotion.platform() ? 1.0 : 1.08) + .animate(CurvedAnimation( + parent: _animationController, curve: Curves.easeOutCubic)); // 초기 _previousServiceKey 설정 _previousServiceKey = _serviceKey; @@ -549,11 +552,14 @@ class _WebsiteIconState extends State @override Widget build(BuildContext context) { - return AnimatedBuilder( + return RepaintBoundary( + child: AnimatedBuilder( animation: _animationController, builder: (context, child) { + final scale = + ReduceMotion.isEnabled(context) ? 1.0 : _scaleAnimation.value; return Transform.scale( - scale: _scaleAnimation.value, + scale: scale, child: child, ); }, @@ -568,7 +574,8 @@ class _WebsiteIconState extends State boxShadow: widget.isHovered ? [ BoxShadow( - color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값 + color: + _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값 blurRadius: 12, spreadRadius: 0, offset: const Offset(0, 4), @@ -578,12 +585,25 @@ class _WebsiteIconState extends State ), child: _buildIconContent(), ), - ); + )); } Widget _buildIconContent() { // 로딩 중 표시 if (_isLoading) { + if (ReduceMotion.isEnabled(context)) { + return Container( + key: ValueKey('loading_${widget.serviceName}_$_uniqueId'), + decoration: BoxDecoration( + color: AppColors.surfaceColorAlt, + borderRadius: BorderRadius.circular(widget.size * 0.2), + border: Border.all( + color: AppColors.borderColor, + width: 0.5, + ), + ), + ); + } return Container( key: ValueKey('loading_${widget.serviceName}_$_uniqueId'), decoration: BoxDecoration( @@ -633,20 +653,31 @@ class _WebsiteIconState extends State width: widget.size, height: widget.size, fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: AppColors.surfaceColorAlt, - child: Center( - child: SizedBox( - width: widget.size * 0.4, - height: widget.size * 0.4, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withValues(alpha: 0.7)), + fadeInDuration: ReduceMotion.isEnabled(context) + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), + fadeOutDuration: ReduceMotion.isEnabled(context) + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), + placeholder: (context, url) { + if (ReduceMotion.isEnabled(context)) { + return Container(color: AppColors.surfaceColorAlt); + } + return Container( + color: AppColors.surfaceColorAlt, + child: Center( + child: SizedBox( + width: widget.size * 0.4, + height: widget.size * 0.4, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppColors.primaryColor.withValues(alpha: 0.7)), + ), ), ), - ), - ), + ); + }, errorWidget: (context, url, error) => _buildFallbackIcon(), ), ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a81f14b..d0a0d33 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -18,7 +18,7 @@ import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) - FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 9581fef..49c6cde 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "6.4.1" ansicolor: dependency: transitive description: @@ -66,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.1" build_config: dependency: transitive description: @@ -90,26 +85,26 @@ packages: dependency: transitive description: name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.13" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "7.3.2" built_collection: dependency: transitive description: @@ -122,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d url: "https://pub.dev" source: hosted - version: "8.9.5" + version: "8.12.0" cached_network_image: dependency: "direct main" description: @@ -162,10 +157,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" cli_util: dependency: transitive description: @@ -242,10 +237,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.8" + version: "2.3.6" dbus: dependency: transitive description: @@ -380,10 +375,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 url: "https://pub.dev" source: hosted - version: "2.0.28" + version: "2.0.30" flutter_secure_storage: dependency: "direct main" description: @@ -444,10 +439,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -462,10 +457,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a + sha256: "27af5982e6c510dec1ba038eff634fa284676ee84e3fd807225c80c4ad869177" url: "https://pub.dev" source: hosted - version: "10.8.0" + version: "10.10.0" frontend_server_client: dependency: transitive description: @@ -534,10 +529,10 @@ packages: dependency: "direct main" description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" http_multi_server: dependency: transitive description: @@ -598,26 +593,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -638,18 +633,18 @@ packages: dependency: transitive description: name: local_auth_android - sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e" + sha256: "1ee0e63fb8b5c6fa286796b5fb1570d256857c2f4a262127e728b36b80a570cf" url: "https://pub.dev" source: hosted - version: "1.0.49" + version: "1.0.53" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2" + sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055" url: "https://pub.dev" source: hosted - version: "1.4.3" + version: "1.6.0" local_auth_platform_interface: dependency: transitive description: @@ -674,14 +669,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" matcher: dependency: transitive description: @@ -766,18 +753,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.18" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -854,10 +841,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.1" platform: dependency: transitive description: @@ -886,18 +873,18 @@ packages: dependency: transitive description: name: posix - sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.0.3" provider: dependency: "direct main" description: name: provider - sha256: "489024f942069c2920c844ee18bb3d467c69e48955a4f32d1677f71be103e310" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -966,10 +953,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.4.12" shared_preferences_foundation: dependency: transitive description: @@ -1022,10 +1009,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.0.1" sky_engine: dependency: transitive description: flutter @@ -1075,18 +1062,18 @@ packages: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2+2" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" sqflite_darwin: dependency: transitive description: @@ -1139,10 +1126,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1155,10 +1142,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" timezone: dependency: "direct main" description: @@ -1195,26 +1182,26 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: dbdd48a5b6a6fe9c7d75099bd2d03f9da9393f8d51a0d250301debbcecd552d2 url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.19" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -1227,10 +1214,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -1267,10 +1254,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 url: "https://pub.dev" source: hosted - version: "1.1.18" + version: "1.1.19" vector_graphics_codec: dependency: transitive description: @@ -1283,34 +1270,34 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc url: "https://pub.dev" source: hosted - version: "1.1.16" + version: "1.1.19" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.3" web: dependency: transitive description: @@ -1323,10 +1310,10 @@ packages: dependency: transitive description: name: web_socket - sha256: bfe6f435f6ec49cb6c01da1e275ae4228719e59a6b067048c51e72d9d63bcc4b + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" web_socket_channel: dependency: transitive description: @@ -1355,26 +1342,26 @@ packages: dependency: transitive description: name: webview_flutter_platform_interface - sha256: "7cb32b21825bd65569665c32bb00a34ded5779786d6201f5350979d2d529940d" + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.14.0" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3 + sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f url: "https://pub.dev" source: hosted - version: "3.22.0" + version: "3.23.0" win32: dependency: transitive description: name: win32 - sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "5.12.0" + version: "5.14.0" xdg_directories: dependency: transitive description: @@ -1387,10 +1374,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -1400,5 +1387,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..b7d812e --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Formatting check" +if command -v dart >/dev/null 2>&1; then + dart format --output=none --set-exit-if-changed . +else + echo "dart not found in PATH" >&2 + exit 1 +fi + +echo "==> Static analysis" +flutter analyze + +echo "==> Tests" +flutter test + +echo "\nAll checks passed." + diff --git a/scripts/fix.sh b/scripts/fix.sh new file mode 100755 index 0000000..718afdc --- /dev/null +++ b/scripts/fix.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Formatting code" +dart format . + +echo "Formatting complete." + diff --git a/test/exchange_rate_service_test.dart b/test/exchange_rate_service_test.dart new file mode 100644 index 0000000..ce3e644 --- /dev/null +++ b/test/exchange_rate_service_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submanager/services/exchange_rate_service.dart'; + +void main() { + test('USD -> KRW conversion returns non-null using defaults when offline', + () async { + final service = ExchangeRateService(); + final krw = await service.convertUsdToTarget(1.0, 'KRW'); + expect(krw, isNotNull); + expect(krw, greaterThan(0)); + }); +} diff --git a/test/url_matcher_test.dart b/test/url_matcher_test.dart new file mode 100644 index 0000000..9096757 --- /dev/null +++ b/test/url_matcher_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submanager/services/subscription_url_matcher.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('extractDomain parses host correctly', () async { + await SubscriptionUrlMatcher.initialize(); + final domain = + SubscriptionUrlMatcher.extractDomain('https://www.netflix.com/kr'); + expect(domain, 'netflix'); + }); + + test('findMatchingUrl finds known service', () async { + await SubscriptionUrlMatcher.initialize(); + final url = SubscriptionUrlMatcher.findMatchingUrl('넷플릭스'); + expect(url, isNotNull); + }); +}