5 Commits

Author SHA1 Message Date
JiWoong Sul
84b3fdd530 perf: 파싱/렌더 최적화 다수 적용
- SmsScanner 키워드/정규식 상수화로 반복 컴파일 제거\n- 리스트에 prototypeItem 추가, 카드 RepaintBoundary 적용\n- 차트 영역 RepaintBoundary로 페인트 분리\n- GlassmorphicScaffold 파티클 수를 disableAnimations에 따라 감소\n- 캐시 초기화 플래그를 --dart-define로 제어(CLEAR_CACHE_ON_STARTUP)
2025-09-07 23:28:18 +09:00
JiWoong Sul
d37f66d526 feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)
- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android)

- 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동

chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가

- AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가

refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입

- SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환

feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화

test: URL 매처/환율 스모크 테스트 추가

chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지)

fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강

fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00
JiWoong Sul
d1a6cb9fe3 style: apply dart format across project 2025-09-07 19:33:11 +09:00
JiWoong Sul
f812d4b9fd feat(permissions): add SMS permission screen and settings button; route from splash on Android 2025-09-07 19:33:11 +09:00
JiWoong Sul
2a90e7c377 chore: add AGENTS.md, helper scripts, codex templates, and CI 2025-09-07 19:33:11 +09:00
118 changed files with 4052 additions and 2899 deletions

13
.claude/agents/codex.md Normal file
View File

@@ -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`.

31
.github/workflows/flutter_ci.yml vendored Normal file
View File

@@ -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

69
AGENTS.md Normal file
View File

@@ -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 multistep tasks, maintain an update_plan with exactly one in_progress step.
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 oneletter 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/<type>-<slug>` (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: <what to do>
Complexity: simple | medium | complex
Context
- Problem / goal:
- Constraints / nongoals:
- Repro or commands:
Done When
- [ ] Behavior verified (`scripts/check.sh` passes)
- [ ] Tests/docs updated if applicable
---
Commands
- Lint/analyze/tests: `scripts/check.sh`
- Autoformat: `scripts/fix.sh`
References & External Facts
- Prefer official docs and codelocal references. If citing sources, include plain URLs or file paths in PR descriptions (avoid footnote citation syntaxes).
Notes from ~/.claude (adapted)
- Fewshot examples improve accuracy; include small before/after or sample input→output when helpful.
- Use structured thinking internally; present only concise, actionable outputs here.

View File

@@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="구독 관리" android:label="구독 관리"
@@ -33,6 +32,10 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- Google AdMob App ID -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6691216385521068~6638409932" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -217,6 +217,17 @@
"enterAmount": "Enter amount", "enterAmount": "Enter amount",
"invalidAmount": "Please enter a valid amount", "invalidAmount": "Please enter a valid amount",
"featureComingSoon": "This feature is coming soon" "featureComingSoon": "This feature is coming soon"
,
"smsPermissionTitle": "Request SMS Permission",
"smsPermissionReasonTitle": "Why",
"smsPermissionReasonBody": "We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.",
"smsPermissionScopeTitle": "Scope",
"smsPermissionScopeBody": "We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.",
"permanentlyDeniedMessage": "Permission is permanently denied. Enable it in Settings.",
"openSettings": "Open Settings",
"later": "Later",
"requesting": "Requesting...",
"smsPermissionLabel": "SMS Permission"
}, },
"ko": { "ko": {
"appTitle": "디지털 월세 관리자", "appTitle": "디지털 월세 관리자",
@@ -436,6 +447,17 @@
"enterAmount": "금액을 입력하세요", "enterAmount": "금액을 입력하세요",
"invalidAmount": "올바른 금액을 입력해주세요", "invalidAmount": "올바른 금액을 입력해주세요",
"featureComingSoon": "이 기능은 곧 출시됩니다" "featureComingSoon": "이 기능은 곧 출시됩니다"
,
"smsPermissionTitle": "SMS 권한 요청",
"smsPermissionReasonTitle": "이유",
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
"smsPermissionScopeTitle": "수집 범위",
"smsPermissionScopeBody": "결제 관련 문자 메시지의 패턴(서비스명/금액/날짜)만 로컬에서 처리하며, 외부로 전송하지 않습니다.",
"permanentlyDeniedMessage": "권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.",
"openSettings": "설정 열기",
"later": "나중에 하기",
"requesting": "요청 중...",
"smsPermissionLabel": "SMS 권한"
}, },
"ja": { "ja": {
"appTitle": "デジタル月額管理者", "appTitle": "デジタル月額管理者",

View File

@@ -0,0 +1,70 @@
Codex Prompt Templates
Note
- Keep prompts concise and specific. Include before/after or small input→output examples when helpful.
- Use the Task Template in AGENTS.md for clarity and a crisp definition of Done.
Next Task Format (from ~/.claude)
---
Next: <what to do>
Complexity: simple | medium | complex
---
Bugfix Prompt
---
Context
- Problem: <symptoms and scope>
- Repro: <steps/command>
- Observed: <actual result>
- Expected: <desired result>
- Constraints / Nongoals: <limits>
Done When
- scripts/check.sh passes; behavior verified via repro
- Tests/docs updated if applicable
---
Small Feature Prompt
---
Context
- Goal: <uservisible behavior>
- Entry points: <screens/routes/widgets>
- Data/State impact: <provider/models/side effects>
- Constraints / Nongoals: <limits>
Done When
- Feature is reachable and works
- scripts/check.sh passes; minimal tests if feasible
---
Refactor Prompt (No Behavior Change)
---
Context
- Target: <files/modules>
- Motivation: <readability/duplication/perf>
- Safety: <no logic change; add tests if risky>
Done When
- Same behavior; cleaner structure
- scripts/check.sh passes
---
UI Change Prompt
---
Context
- Screen/Widget: <where>
- Visual Goal: <what changes>
- Theming/Adaptivity: <light/dark/platform>
Done When
- Visual change implemented; screenshots added in PR by human
- scripts/check.sh passes
---
Code Review Aid
---
- Summarize intent and key diffs
- Verify formatting, analysis, and tests pass
- Flag risks; suggest targeted followups
---

View File

@@ -47,5 +47,8 @@
<true/> <true/>
<key>NSMessageUsageDescription</key> <key>NSMessageUsageDescription</key>
<string>구독 결제 정보를 자동으로 추가하기 위해 SMS 접근이 필요합니다.</string> <string>구독 결제 정보를 자동으로 추가하기 위해 SMS 접근이 필요합니다.</string>
<!-- Google AdMob App ID -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-6691216385521068~6638409932</string>
</dict> </dict>
</plist> </plist>

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
@@ -158,12 +157,14 @@ class AddSubscriptionController {
serviceNameController.text = serviceInfo.serviceName; serviceNameController.text = serviceInfo.serviceName;
// 카테고리 자동 선택 // 카테고리 자동 선택
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories; final categories = categoryProvider.categories;
// 카테고리 ID로 매칭 // 카테고리 ID로 매칭
final matchedCategory = categories.firstWhere( final matchedCategory = categories.firstWhere(
(cat) => cat.name == serviceInfo.categoryNameKr || (cat) =>
cat.name == serviceInfo.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn, cat.name == serviceInfo.categoryNameEn,
orElse: () => categories.first, orElse: () => categories.first,
); );
@@ -174,12 +175,14 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showSuccess( AppSnackBar.showSuccess(
context: context, context: context,
message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName), message: AppLocalizations.of(context)
.serviceRecognized(serviceInfo.serviceName),
); );
} }
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e'); print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
} }
} }
@@ -187,7 +190,8 @@ class AddSubscriptionController {
/// 카테고리 자동 선택 /// 카테고리 자동 선택
void autoSelectCategory() { void autoSelectCategory() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories; final categories = categoryProvider.categories;
final serviceName = serviceNameController.text.toLowerCase(); final serviceName = serviceNameController.text.toLowerCase();
@@ -312,9 +316,11 @@ class AddSubscriptionController {
if (smsContent.isNotEmpty) { if (smsContent.isNotEmpty) {
try { try {
serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent); serviceInfo =
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e'); print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
} }
} }
@@ -327,11 +333,13 @@ class AddSubscriptionController {
websiteUrlController.text = serviceInfo.serviceUrl ?? ''; websiteUrlController.text = serviceInfo.serviceUrl ?? '';
// 카테고리 자동 선택 // 카테고리 자동 선택
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories; final categories = categoryProvider.categories;
final matchedCategory = categories.firstWhere( final matchedCategory = categories.firstWhere(
(cat) => cat.name == serviceInfo!.categoryNameKr || (cat) =>
cat.name == serviceInfo!.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn, cat.name == serviceInfo.categoryNameEn,
orElse: () => categories.first, orElse: () => categories.first,
); );
@@ -396,7 +404,8 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()), message: AppLocalizations.of(context)
.smsScanErrorWithMessage(e.toString()),
); );
} }
} finally { } finally {
@@ -421,9 +430,8 @@ class AddSubscriptionController {
// 이벤트 가격 파싱 // 이벤트 가격 파싱
double? eventPrice; double? eventPrice;
if (isEventActive && eventPriceController.text.isNotEmpty) { if (isEventActive && eventPriceController.text.isNotEmpty) {
eventPrice = double.tryParse( eventPrice =
eventPriceController.text.replaceAll(',', '') double.tryParse(eventPriceController.text.replaceAll(',', ''));
);
} }
await Provider.of<SubscriptionProvider>(context, listen: false) await Provider.of<SubscriptionProvider>(context, listen: false)
@@ -452,7 +460,8 @@ class AddSubscriptionController {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()), message:
AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
); );
} }
} }

View File

@@ -140,9 +140,12 @@ class DetailScreenController extends ChangeNotifier {
/// 초기화 /// 초기화
void initialize({required TickerProvider vsync}) { void initialize({required TickerProvider vsync}) {
// Text Controllers 초기화 // Text Controllers 초기화
serviceNameController = TextEditingController(text: subscription.serviceName); serviceNameController =
monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString()); TextEditingController(text: subscription.serviceName);
websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? ''); monthlyCostController =
TextEditingController(text: subscription.monthlyCost.toString());
websiteUrlController =
TextEditingController(text: subscription.websiteUrl ?? '');
eventPriceController = TextEditingController(); eventPriceController = TextEditingController();
// Form State 초기화 // Form State 초기화
@@ -261,10 +264,12 @@ class DetailScreenController extends ChangeNotifier {
if (_currency == 'KRW') { if (_currency == 'KRW') {
// 원화는 소수점 없이 표시 // 원화는 소수점 없이 표시
final intValue = subscription.monthlyCost.toInt(); final intValue = subscription.monthlyCost.toInt();
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue); monthlyCostController.text =
NumberFormat.decimalPattern().format(intValue);
} else { } else {
// 달러는 소수점 2자리까지 표시 // 달러는 소수점 2자리까지 표시
monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost); monthlyCostController.text =
NumberFormat('#,##0.00').format(subscription.monthlyCost);
} }
} }
@@ -275,7 +280,8 @@ class DetailScreenController extends ChangeNotifier {
/// 카테고리 자동 선택 /// 카테고리 자동 선택
void autoSelectCategory() { void autoSelectCategory() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories; final categories = categoryProvider.categories;
final serviceName = serviceNameController.text.toLowerCase(); final serviceName = serviceNameController.text.toLowerCase();
@@ -377,7 +383,8 @@ class DetailScreenController extends ChangeNotifier {
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
String? websiteUrl = websiteUrlController.text; String? websiteUrl = websiteUrlController.text;
if (websiteUrl.isEmpty) { if (websiteUrl.isEmpty) {
websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text); websiteUrl =
SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
} }
// 구독 정보 업데이트 // 구독 정보 업데이트
@@ -385,7 +392,8 @@ class DetailScreenController extends ChangeNotifier {
// 콤마 제거하고 숫자만 추출 // 콤마 제거하고 숫자만 추출
double monthlyCost = 0.0; double monthlyCost = 0.0;
try { try {
monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', '')); monthlyCost =
double.parse(monthlyCostController.text.replaceAll(',', ''));
} catch (e) { } catch (e) {
// 파싱 오류 발생 시 기본값 사용 // 파싱 오류 발생 시 기본값 사용
monthlyCost = subscription.monthlyCost; monthlyCost = subscription.monthlyCost;
@@ -393,7 +401,7 @@ class DetailScreenController extends ChangeNotifier {
debugPrint('[DetailScreenController] 구독 업데이트 시작: ' debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, ' '${subscription.serviceName}${serviceNameController.text}, '
'금액: ${subscription.monthlyCost}$monthlyCost ${_currency}'); '금액: $subscription.monthlyCost → $monthlyCost $_currency');
subscription.serviceName = serviceNameController.text; subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
@@ -445,30 +453,35 @@ class DetailScreenController extends ChangeNotifier {
Future<void> deleteSubscription() async { Future<void> deleteSubscription() async {
if (context.mounted) { if (context.mounted) {
// 로케일에 맞는 서비스명 가져오기 // 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(context, listen: false); final localeProvider =
Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode; final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName, serviceName: subscription.serviceName,
locale: locale, locale: locale,
); );
if (!context.mounted) return;
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show( final shouldDelete = await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: displayName, serviceName: displayName,
); );
if (!context.mounted) return;
if (!shouldDelete) return; if (!shouldDelete) return;
// 사용자가 확인한 경우에만 삭제 진행 // 사용자가 확인한 경우에만 삭제 진행
if (context.mounted) { if (context.mounted) {
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
await provider.deleteSubscription(subscription.id); await provider.deleteSubscription(subscription.id);
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context).subscriptionDeleted(displayName), message:
AppLocalizations.of(context).subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded, icon: Icons.delete_forever_rounded,
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -484,7 +497,8 @@ class DetailScreenController extends ChangeNotifier {
final locale = Localizations.localeOf(context).languageCode; final locale = Localizations.localeOf(context).languageCode;
// 2. 해지 안내 URL 찾기 // 2. 해지 안내 URL 찾기
String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl( String? cancellationUrl =
await SubscriptionUrlMatcher.findCancellationUrl(
serviceName: subscription.serviceName, serviceName: subscription.serviceName,
websiteUrl: subscription.websiteUrl, websiteUrl: subscription.websiteUrl,
locale: locale == 'ko' ? 'kr' : 'en', locale: locale == 'ko' ? 'kr' : 'en',
@@ -492,8 +506,10 @@ class DetailScreenController extends ChangeNotifier {
// 3. 해지 안내 URL이 없으면 구글 검색 // 3. 해지 안내 URL이 없으면 구글 검색
if (cancellationUrl == null) { if (cancellationUrl == null) {
final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}'; final searchQuery =
cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}'; '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
cancellationUrl =
'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
if (context.mounted) { if (context.mounted) {
AppSnackBar.showInfo( AppSnackBar.showInfo(
@@ -515,6 +531,7 @@ class DetailScreenController extends ChangeNotifier {
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
// ignore: avoid_print
print('DetailScreenController: 해지 페이지 열기 실패 - $e'); print('DetailScreenController: 해지 페이지 열기 실패 - $e');
} }

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/sms_scanner.dart'; import '../services/sms_scanner.dart';
import '../models/subscription.dart'; import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../utils/logger.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -58,18 +58,20 @@ class SmsScanController extends ChangeNotifier {
try { try {
// SMS 스캔 실행 // SMS 스캔 실행
print('SMS 스캔 시작'); Log.i('SMS 스캔 시작');
final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions(); final scannedSubscriptionModels =
print('스캔된 구독: ${scannedSubscriptionModels.length}'); await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) { if (scannedSubscriptionModels.isNotEmpty) {
print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}'); Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
} }
if (!context.mounted) return; if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) { if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음'); Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound; _errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -77,18 +79,21 @@ class SmsScanController extends ChangeNotifier {
} }
// SubscriptionModel을 Subscription으로 변환 // SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels); final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링 // 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2); final repeatSubscriptions =
print('반복 결제된 구독: ${repeatSubscriptions.length}'); _filter.filterByRepeatCount(scannedSubscriptions, 2);
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) { if (repeatSubscriptions.isNotEmpty) {
print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}'); Log.d(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
} }
if (repeatSubscriptions.isEmpty) { if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음'); Log.i('반복 결제된 구독이 없음');
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
@@ -96,21 +101,24 @@ class SmsScanController extends ChangeNotifier {
} }
// 구독 목록 가져오기 // 구독 목록 가져오기
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions; final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}'); Log.d('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링 // 중복 구독 필터링
final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); final filteredSubscriptions =
print('중복 제거 후 구독: ${filteredSubscriptions.length}'); _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) { if (filteredSubscriptions.isNotEmpty) {
print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); Log.d(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
} }
// 중복 제거 후 신규 구독이 없는 경우 // 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) { if (filteredSubscriptions.isEmpty) {
print('중복 제거 후 신규 구독이 없음'); Log.i('중복 제거 후 신규 구독이 없음');
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return; return;
@@ -121,9 +129,10 @@ class SmsScanController extends ChangeNotifier {
websiteUrlController.text = ''; // URL 입력 필드 초기화 websiteUrlController.text = ''; // URL 입력 필드 초기화
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
print('SMS 스캔 중 오류 발생: $e'); Log.e('SMS 스캔 중 오류 발생', e);
if (context.mounted) { if (context.mounted) {
_errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); _errorMessage =
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
} }
@@ -136,17 +145,22 @@ class SmsScanController extends ChangeNotifier {
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
try { try {
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider =
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider); final finalCategoryId = _selectedCategoryId ??
subscription.category ??
getDefaultCategoryId(categoryProvider);
// websiteUrl 처리 // websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim() ? websiteUrlController.text.trim()
: subscription.websiteUrl; : subscription.websiteUrl;
print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl'); Log.d(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출 // addSubscription 호출
await provider.addSubscription( await provider.addSubscription(
@@ -162,19 +176,20 @@ class SmsScanController extends ChangeNotifier {
currency: subscription.currency, currency: subscription.currency,
); );
print('구독 추가 성공: ${subscription.serviceName}'); Log.i('구독 추가 성공: ${subscription.serviceName}');
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} catch (e) { } catch (e) {
print('구독 추가 중 오류 발생: $e'); Log.e('구독 추가 중 오류 발생', e);
// 오류가 있어도 다음 구독으로 이동 // 오류가 있어도 다음 구독으로 이동
if (!context.mounted) return;
moveToNextSubscription(context); moveToNextSubscription(context);
} }
} }
void skipCurrentSubscription(BuildContext context) { void skipCurrentSubscription(BuildContext context) {
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
print('구독 건너뛰기: ${subscription.serviceName}'); Log.i('구독 건너뛰기: ${subscription.serviceName}');
moveToNextSubscription(context); moveToNextSubscription(context);
} }
@@ -193,7 +208,8 @@ class SmsScanController extends ChangeNotifier {
void navigateToHome(BuildContext context) { void navigateToHome(BuildContext context) {
// NavigationProvider를 사용하여 홈 화면으로 이동 // NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false); final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0); navigationProvider.updateCurrentIndex(0);
} }
@@ -209,7 +225,7 @@ class SmsScanController extends ChangeNotifier {
(cat) => cat.name == 'other', (cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first, orElse: () => categoryProvider.categories.first,
); );
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id; return otherCategory.id;
} }

View File

@@ -14,22 +14,21 @@ class AppLocalizations {
// JSON 파일에서 번역 데이터 로드 // JSON 파일에서 번역 데이터 로드
Future<void> load() async { Future<void> load() async {
String jsonString = String jsonString = await rootBundle.loadString('assets/data/text.json');
await rootBundle.loadString('assets/data/text.json');
Map<String, dynamic> jsonMap = json.decode(jsonString); Map<String, dynamic> jsonMap = json.decode(jsonString);
_localizedStrings = jsonMap[locale.languageCode]; _localizedStrings = jsonMap[locale.languageCode];
} }
String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager'; 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 => String get subscriptionManagement =>
_localizedStrings['subscriptionManagement'] ?? 'Subscription Management'; _localizedStrings['subscriptionManagement'] ?? 'Subscription Management';
String get addSubscription => String get addSubscription =>
_localizedStrings['addSubscription'] ?? 'Add Subscription'; _localizedStrings['addSubscription'] ?? 'Add Subscription';
String get subscriptionName => String get subscriptionName =>
_localizedStrings['subscriptionName'] ?? 'Service Name'; _localizedStrings['subscriptionName'] ?? 'Service Name';
String get monthlyCost => String get monthlyCost => _localizedStrings['monthlyCost'] ?? 'Monthly Cost';
_localizedStrings['monthlyCost'] ?? 'Monthly Cost';
String get billingCycle => String get billingCycle =>
_localizedStrings['billingCycle'] ?? 'Billing Cycle'; _localizedStrings['billingCycle'] ?? 'Billing Cycle';
String get nextBillingDate => String get nextBillingDate =>
@@ -55,29 +54,50 @@ class AppLocalizations {
_localizedStrings['categoryManagement'] ?? 'Category Management'; _localizedStrings['categoryManagement'] ?? 'Category Management';
String get categoryName => String get categoryName =>
_localizedStrings['categoryName'] ?? 'Category Name'; _localizedStrings['categoryName'] ?? 'Category Name';
String get selectColor => String get selectColor => _localizedStrings['selectColor'] ?? 'Select Color';
_localizedStrings['selectColor'] ?? 'Select Color'; String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
String get selectIcon => String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
_localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory =>
_localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedStrings['settings'] ?? 'Settings'; String get settings => _localizedStrings['settings'] ?? 'Settings';
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode'; String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedStrings['language'] ?? 'Language'; String get language => _localizedStrings['language'] ?? 'Language';
String get notifications => String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications'; _localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock'; 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 => String get notificationPermission =>
_localizedStrings['notificationPermission'] ?? 'Notification Permission'; _localizedStrings['notificationPermission'] ?? 'Notification Permission';
String get notificationPermissionDesc => String get notificationPermissionDesc =>
_localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications'; _localizedStrings['notificationPermissionDesc'] ??
'Permission is required to receive notifications';
String get requestPermission => String get requestPermission =>
_localizedStrings['requestPermission'] ?? 'Request Permission'; _localizedStrings['requestPermission'] ?? 'Request Permission';
String get paymentNotification => String get paymentNotification =>
_localizedStrings['paymentNotification'] ?? 'Payment Due Notification'; _localizedStrings['paymentNotification'] ?? 'Payment Due Notification';
String get paymentNotificationDesc => String get paymentNotificationDesc =>
_localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date'; _localizedStrings['paymentNotificationDesc'] ??
'Receive notification on payment due date';
String get notificationTiming => String get notificationTiming =>
_localizedStrings['notificationTiming'] ?? 'Notification Timing'; _localizedStrings['notificationTiming'] ?? 'Notification Timing';
String get notificationTime => String get notificationTime =>
@@ -85,11 +105,14 @@ class AppLocalizations {
String get dailyReminder => String get dailyReminder =>
_localizedStrings['dailyReminder'] ?? 'Daily Reminder'; _localizedStrings['dailyReminder'] ?? 'Daily Reminder';
String get dailyReminderEnabled => String get dailyReminderEnabled =>
_localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date'; _localizedStrings['dailyReminderEnabled'] ??
'Receive daily notifications until payment date';
String get dailyReminderDisabled => String get dailyReminderDisabled =>
_localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment'; _localizedStrings['dailyReminderDisabled'] ??
'Receive notification @ day(s) before payment';
String get notificationPermissionDenied => String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied'; _localizedStrings['notificationPermissionDenied'] ??
'Notification permission denied';
// 앱 정보 // 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info'; String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version'; String get version => _localizedStrings['version'] ?? 'Version';
@@ -102,7 +125,8 @@ class AppLocalizations {
String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light'; String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light';
String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark'; String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark';
String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black'; String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black';
String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default'; String get systemTheme =>
_localizedStrings['systemTheme'] ?? 'System Default';
// 기타 메시지 // 기타 메시지
String get subscriptionAdded => String get subscriptionAdded =>
_localizedStrings['subscriptionAdded'] ?? 'Subscription added'; _localizedStrings['subscriptionAdded'] ?? 'Subscription added';
@@ -112,72 +136,133 @@ class AppLocalizations {
String get japanese => _localizedStrings['japanese'] ?? '日本語'; String get japanese => _localizedStrings['japanese'] ?? '日本語';
String get chinese => _localizedStrings['chinese'] ?? '中文'; String get chinese => _localizedStrings['chinese'] ?? '中文';
// 날짜 // 날짜
String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before'; String get oneDayBefore =>
String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before'; _localizedStrings['oneDayBefore'] ?? '1 day before';
String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days 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 requiredFieldsError =>
String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated'; _localizedStrings['requiredFieldsError'] ??
String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.'; 'Please fill in all required fields';
String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website'; String get subscriptionUpdated =>
String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.'; _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 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 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 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 eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price'; String get eventPriceHint =>
String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price'; _localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid 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 smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
String get home => _localizedStrings['home'] ?? 'Home'; String get home => _localizedStrings['home'] ?? 'Home';
String get analysis => _localizedStrings['analysis'] ?? 'Analysis'; String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
String get back => _localizedStrings['back'] ?? 'Back'; String get back => _localizedStrings['back'] ?? 'Back';
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App'; 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 exit => _localizedStrings['exit'] ?? 'Exit';
String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found'; String get pageNotFound =>
String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify'; _localizedStrings['pageNotFound'] ?? 'Page not found';
String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com'; String get serviceNameExample =>
String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication'; _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication'; String get urlExample =>
String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.'; _localizedStrings['urlExample'] ?? 'https://example.com';
String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required'; String get appLockDesc =>
String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found'; _localizedStrings['appLockDesc'] ??
String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan'; 'App lock with biometric authentication';
String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving'; String get unlockWithBiometric =>
String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found'; _localizedStrings['unlockWithBiometric'] ??
String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription'; 'Unlock with biometric authentication';
String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.'; String get authenticationFailed =>
String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)'; _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 startDate => _localizedStrings['startDate'] ?? 'Start Date';
String get endDate => _localizedStrings['endDate'] ?? 'End Date'; String get endDate => _localizedStrings['endDate'] ?? 'End Date';
// 새로 추가된 항목들 // 새로 추가된 항목들
String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost'; String get monthlyTotalSubscriptionCost =>
String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate'; _localizedStrings['monthlyTotalSubscriptionCost'] ??
'Total Monthly Subscription Cost';
String get todaysExchangeRate =>
_localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
String get won => _localizedStrings['won'] ?? 'KRW'; String get won => _localizedStrings['won'] ?? 'KRW';
String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost'; String get estimatedAnnualCost =>
String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services'; _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
String get totalSubscriptionServices =>
_localizedStrings['totalSubscriptionServices'] ??
'Total Subscription Services';
String get services => _localizedStrings['services'] ?? '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 saving => _localizedStrings['saving'] ?? 'Saving';
String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today'; String get paymentDueToday =>
String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed'; _localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
String get paymentInfoNeeded =>
_localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
String get event => _localizedStrings['event'] ?? 'Event'; String get event => _localizedStrings['event'] ?? 'Event';
// 카테고리 getter들 // 카테고리 getter들
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music'; String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)'; String get categoryOttVideo =>
String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud'; _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV'; String get categoryStorageCloud =>
String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle'; _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce'; String get categoryTelecomInternetTv =>
String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming'; _localizedStrings['categoryTelecomInternetTv'] ??
String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office'; 'Telecom · Internet · TV';
String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service'; 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 get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
// 동적 메시지 생성 메서드 // 동적 메시지 생성 메서드
@@ -186,115 +271,166 @@ class AppLocalizations {
} }
String dailyReminderDisabledWithDays(int days) { 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()); return template.replaceAll('@', days.toString());
} }
String subscriptionAddedWithName(String serviceName) { String subscriptionAddedWithName(String serviceName) {
final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.'; final template =
_localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
return template.replaceAll('@', serviceName); return template.replaceAll('@', serviceName);
} }
String subscriptionDeleted(String 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); return template.replaceAll('@', serviceName);
} }
String totalExpenseCopied(String amount) { String totalExpenseCopied(String amount) {
final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @'; final template =
_localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
return template.replaceAll('@', amount); return template.replaceAll('@', amount);
} }
String serviceRecognized(String serviceName) { 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); return template.replaceAll('@', serviceName);
} }
String smsScanErrorWithMessage(String error) { 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); return template.replaceAll('@', error);
} }
String saveErrorWithMessage(String 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); return template.replaceAll('@', error);
} }
String subscriptionAddErrorWithMessage(String error) { String subscriptionAddErrorWithMessage(String error) {
final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @'; final template = _localizedStrings['subscriptionAddError'] ??
'Error adding subscription: @';
return template.replaceAll('@', error); return template.replaceAll('@', error);
} }
String subscriptionSkipped(String serviceName) { String subscriptionSkipped(String serviceName) {
final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.'; final template =
_localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
return template.replaceAll('@', serviceName); return template.replaceAll('@', serviceName);
} }
// 홈화면 관련 // 홈화면 관련
String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions'; String get mySubscriptions =>
_localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
String subscriptionCount(int count) { String subscriptionCount(int count) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
return '${count}'; return '$count개';
} else if (locale.languageCode == 'ja') { } else if (locale.languageCode == 'ja') {
return '${count}'; return '$count個';
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${count}'; return '$count个';
} else { } else {
return count.toString(); return count.toString();
} }
} }
// 분석화면 관련 // 분석화면 관련
String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status'; String get monthlyExpenseTitle =>
String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend'; _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense'; String get recentSixMonthsTrend =>
String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio'; _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense'; String get monthlySubscriptionExpense =>
String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services'; _localizedStrings['monthlySubscriptionExpense'] ??
String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary'; 'Monthly subscription expense';
String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount'; String get subscriptionServiceRatio =>
String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense'; _localizedStrings['subscriptionServiceRatio'] ??
String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services'; '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 servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost'; String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status'; String get eventDiscountStatus =>
String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress'; _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount'; String get inProgressUnit =>
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress'; _localizedStrings['inProgressUnit'] ?? 'in progress';
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount'; 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'; String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
// SMS 스캔 관련 // SMS 스캔 관련
String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...'; String get scanningMessages =>
String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services'; _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.'; String get findingSubscriptions =>
String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.'; _localizedStrings['findingSubscriptions'] ??
String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found'; 'Finding subscription services';
String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times'; String get subscriptionNotFound =>
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.'; _localizedStrings['subscriptionNotFound'] ??
String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning'; 'Subscription information not found.';
String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription'; 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 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 category => _localizedStrings['category'] ?? 'Category';
String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)'; String get websiteUrlAuto =>
String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty'; _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 skip => _localizedStrings['skip'] ?? 'Skip';
String get add => _localizedStrings['add'] ?? 'Add'; 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) { 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()); return template.replaceAll('@', date).replaceAll('#', days.toString());
} }
String nextBillingDateInfo(String date, int days) { 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()); 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) { String formatDate(DateTime date) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
@@ -304,23 +440,37 @@ class AppLocalizations {
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${date.year}${date.month}${date.day}'; return '${date.year}${date.month}${date.day}';
} else { } 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}'; return '${months[date.month - 1]} ${date.day}, ${date.year}';
} }
} }
String repeatCountDetected(int count) { String repeatCountDetected(int count) {
final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected'; final template =
_localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
return template.replaceAll('@', count.toString()); return template.replaceAll('@', count.toString());
} }
String servicesInProgress(int count) { String servicesInProgress(int count) {
if (locale.languageCode == 'ko') { if (locale.languageCode == 'ko') {
return '${count} 진행중'; return '$count 진행중';
} else if (locale.languageCode == 'ja') { } else if (locale.languageCode == 'ja') {
return '${count}個進行中'; return '$count個進行中';
} else if (locale.languageCode == 'zh') { } else if (locale.languageCode == 'zh') {
return '${count}个进行中'; return '$count个进行中';
} else { } else {
return '$count in progress'; return '$count in progress';
} }
@@ -328,7 +478,8 @@ class AppLocalizations {
// 새로 추가된 동적 메서드들 // 새로 추가된 동적 메서드들
String paymentDueInDays(int days) { 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()); return template.replaceAll('@', days.toString());
} }
@@ -338,27 +489,37 @@ class AppLocalizations {
} }
String exchangeRateFormat(String rate) { String exchangeRateFormat(String rate) {
final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @'; final template =
_localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
return template.replaceAll('@', rate); return template.replaceAll('@', rate);
} }
// 결제 주기 결제 메시지 // 결제 주기 결제 메시지
String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment'; String get billingCyclePayment =>
_localizedStrings['billingCyclePayment'] ?? '@ Payment';
// 할인 금액 표시 getter들 // 할인 금액 표시 getter들
String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@'; String get discountAmountWon =>
String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@'; _localizedStrings['discountAmountWon'] ?? 'Save @';
String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@'; String get discountAmountDollar =>
String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@'; _localizedStrings['discountAmountDollar'] ?? 'Save \$@';
String get discountAmountYen =>
_localizedStrings['discountAmountYen'] ?? 'Save ¥@';
String get discountAmountYuan =>
_localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
// 결제 주기 관련 getter // 결제 주기 관련 getter
String get monthly => _localizedStrings['monthly'] ?? 'Monthly'; String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
String get weekly => _localizedStrings['weekly'] ?? 'Weekly'; String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
String get yearly => _localizedStrings['yearly'] ?? 'Yearly'; String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly'; String get billingCycleMonthly =>
String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly'; _localizedStrings['billingCycleMonthly'] ?? 'Monthly';
String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly'; String get billingCycleQuarterly =>
String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly'; _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
String get billingCycleHalfYearly =>
_localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
String get billingCycleYearly =>
_localizedStrings['billingCycleYearly'] ?? 'Yearly';
// 색상 관련 getter // 색상 관련 getter
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue'; String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
@@ -368,48 +529,75 @@ class AppLocalizations {
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple'; String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
// 날짜 형식 관련 getter // 날짜 형식 관련 getter
String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy'; String get dateFormatFull =>
_localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd'; String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
// USD 환율 표시 형식 // USD 환율 표시 형식
String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @'; String get exchangeRateDisplay =>
_localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
// 라벨 및 힌트 텍스트 // 라벨 및 힌트 텍스트
String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name'; String get labelServiceName =>
String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify'; _localizedStrings['labelServiceName'] ?? 'Service Name';
String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense'; String get hintServiceName =>
String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date'; _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)'; String get labelMonthlyExpense =>
String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com'; _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price'; String get labelNextBillingDate =>
String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price'; _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 labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
// 기타 번역 // 기타 번역
String get subscription => _localizedStrings['subscription'] ?? 'Subscription'; String get subscription =>
_localizedStrings['subscription'] ?? 'Subscription';
String get movie => _localizedStrings['movie'] ?? 'Movie'; 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 exercise => _localizedStrings['exercise'] ?? 'Exercise';
String get shopping => _localizedStrings['shopping'] ?? 'Shopping'; String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
String get currency => _localizedStrings['currency'] ?? 'Currency'; String get currency => _localizedStrings['currency'] ?? 'Currency';
String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information'; String get websiteInfo =>
String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide'; _localizedStrings['websiteInfo'] ?? 'Website Information';
String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.'; String get cancelGuide =>
String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page'; _localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name'; 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'; String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
// 새로 추가된 getter들 // 새로 추가된 getter들
String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information'; String get serviceInfo =>
String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription'; _localizedStrings['serviceInfo'] ?? 'Service Information';
String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information'; String get newSubscriptionAdd =>
String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription'; _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name'; String get enterServiceInfo =>
String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount'; _localizedStrings['enterServiceInfo'] ?? 'Enter service information';
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; 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 enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; String get invalidAmount =>
String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
// 결제 주기를 키값으로 변환하여 번역된 이름 반환 // 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) { String getBillingCycleName(String billingCycleKey) {
@@ -467,7 +655,8 @@ class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const AppLocalizationsDelegate(); const AppLocalizationsDelegate();
@override @override
bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode); bool isSupported(Locale locale) =>
['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
@override @override
Future<AppLocalizations> load(Locale locale) async { Future<AppLocalizations> load(Locale locale) async {

View File

@@ -22,11 +22,12 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:async' show unawaited; import 'dart:async' show unawaited;
import 'utils/memory_manager.dart'; import 'utils/memory_manager.dart';
import 'utils/logger.dart';
import 'utils/performance_optimizer.dart'; import 'utils/performance_optimizer.dart';
import 'navigator_key.dart'; import 'navigator_key.dart';
// AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경) // AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경)
const bool enableAdMob = false; const bool enableAdMob = true;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -44,16 +45,23 @@ Future<void> main() async {
try { try {
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비 // 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지) // 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
// 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
const bool clearCacheOnStartup = bool.fromEnvironment(
'CLEAR_CACHE_ON_STARTUP',
defaultValue: false,
);
if (clearCacheOnStartup) {
await DefaultCacheManager().emptyCache(); await DefaultCacheManager().emptyCache();
}
if (kDebugMode) { if (kDebugMode) {
print('이미지 캐시 관리 초기화 완료'); Log.d('이미지 캐시 관리 초기화 완료');
PerformanceOptimizer.checkConstOptimization(); PerformanceOptimizer.checkConstOptimization();
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('캐시 초기화 오류: $e'); Log.e('캐시 초기화 오류', e);
} }
} }

View File

@@ -37,7 +37,8 @@ class AppNavigationObserver extends NavigatorObserver {
if (newRoute != null) { if (newRoute != null) {
_updateNavigationState(newRoute); _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<dynamic> route) { void _updateNavigationState(Route<dynamic> route) {
@@ -52,7 +53,8 @@ class AppNavigationObserver extends NavigatorObserver {
try { try {
final context = navigator!.context; final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false); final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateByRoute(routeName); navigationProvider.updateByRoute(routeName);
} catch (e) { } catch (e) {
debugPrint('Failed to update navigation state: $e'); debugPrint('Failed to update navigation state: $e');
@@ -69,7 +71,8 @@ class AppNavigationObserver extends NavigatorObserver {
try { try {
final context = navigator!.context; final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false); final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.pop(); navigationProvider.pop();
} catch (e) { } catch (e) {
debugPrint('Failed to handle pop with provider: $e'); debugPrint('Failed to handle pop with provider: $e');

View File

@@ -59,9 +59,17 @@ class CategoryProvider extends ChangeNotifier {
{'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'}, {'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
{'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'}, {'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
{'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'}, {'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': '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': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
{'name': 'other', 'color': '#9E9E9E', 'icon': 'category'}, {'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
]; ];

View File

@@ -270,7 +270,8 @@ class NotificationProvider extends ChangeNotifier {
// 첫 권한 부여 시 기본 설정 초기화 // 첫 권한 부여 시 기본 설정 초기화
Future<void> initializeDefaultSettingsOnFirstPermission() async { Future<void> initializeDefaultSettingsOnFirstPermission() async {
try { try {
final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey); final firstGranted =
await _secureStorage.read(key: _firstPermissionGrantedKey);
if (firstGranted != 'true') { if (firstGranted != 'true') {
// 첫 권한 부여 시 기본값 설정 // 첫 권한 부여 시 기본값 설정
await setReminderDays(2); // 2일 전 알림 await setReminderDays(2); // 2일 전 알림
@@ -278,7 +279,8 @@ class NotificationProvider extends ChangeNotifier {
await setPaymentEnabled(true); // 결제 예정 알림 활성화 await setPaymentEnabled(true); // 결제 예정 알림 활성화
// 첫 권한 부여 플래그 저장 // 첫 권한 부여 플래그 저장
await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true'); await _secureStorage.write(
key: _firstPermissionGrantedKey, value: 'true');
} }
} catch (e) { } catch (e) {
debugPrint('기본 설정 초기화 중 오류 발생: $e'); debugPrint('기본 설정 초기화 중 오류 발생: $e');

View File

@@ -28,10 +28,11 @@ class SubscriptionProvider extends ChangeNotifier {
final price = subscription.currentPrice; final price = subscription.currentPrice;
if (subscription.currency == 'USD') { if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$${price} ×$rate = ₩${price * rate}'); '\$$price ×$rate = ₩${price * rate}');
return sum + (price * rate); return sum + (price * rate);
} }
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); debugPrint(
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
return sum + price; return sum + price;
}, },
); );
@@ -191,7 +192,6 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
Future<void> clearAllSubscriptions() async { Future<void> clearAllSubscriptions() async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
@@ -217,7 +217,8 @@ class SubscriptionProvider extends ChangeNotifier {
} }
/// 이벤트 종료 알림을 스케줄링합니다. /// 이벤트 종료 알림을 스케줄링합니다.
Future<void> _scheduleEventEndNotification(SubscriptionModel subscription) async { Future<void> _scheduleEventEndNotification(
SubscriptionModel subscription) async {
if (subscription.eventEndDate != null && if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) { subscription.eventEndDate!.isAfter(DateTime.now())) {
await NotificationService.scheduleNotification( await NotificationService.scheduleNotification(
@@ -238,7 +239,6 @@ class SubscriptionProvider extends ChangeNotifier {
if (subscription.isEventActive && if (subscription.isEventActive &&
subscription.eventEndDate != null && subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(DateTime.now())) { subscription.eventEndDate!.isBefore(DateTime.now())) {
subscription.isEventActive = false; subscription.isEventActive = false;
await _subscriptionBox.put(subscription.id, subscription); await _subscriptionBox.put(subscription.id, subscription);
hasChanges = true; hasChanges = true;
@@ -255,9 +255,8 @@ class SubscriptionProvider extends ChangeNotifier {
if (_subscriptions.isEmpty) return 0.0; if (_subscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용 // locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null final targetCurrency =
? CurrencyUtil.getDefaultCurrency(locale) locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
: 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency'); debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
double total = 0.0; double total = 0.0;
@@ -265,7 +264,7 @@ class SubscriptionProvider extends ChangeNotifier {
for (final subscription in _subscriptions) { for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice, currentPrice,
@@ -281,14 +280,14 @@ class SubscriptionProvider extends ChangeNotifier {
} }
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({String? locale}) async { Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
{String? locale}) async {
final now = DateTime.now(); final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = []; final List<Map<String, dynamic>> monthlyData = [];
// locale이 제공되지 않으면 현재 로케일 사용 // locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null final targetCurrency =
? CurrencyUtil.getDefaultCurrency(locale) locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
: 'KRW'; // 기본값
// 최근 6개월 데이터 생성 // 최근 6개월 데이터 생성
for (int i = 5; i >= 0; i--) { for (int i = 5; i >= 0; i--) {
@@ -296,10 +295,12 @@ class SubscriptionProvider extends ChangeNotifier {
double monthTotal = 0.0; 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) { if (isCurrentMonth) {
debugPrint('[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); debugPrint(
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
} }
// 해당 월에 활성화된 구독 계산 // 해당 월에 활성화된 구독 계산
@@ -307,11 +308,13 @@ class SubscriptionProvider extends ChangeNotifier {
if (isCurrentMonth) { if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게) // 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice; final cost = subscription.currentPrice;
debugPrint('[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' debugPrint(
'${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
// 통화 변환 // 통화 변환
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost, cost,
subscription.currency, subscription.currency,
targetCurrency, targetCurrency,
@@ -325,7 +328,8 @@ class SubscriptionProvider extends ChangeNotifier {
Duration(days: _getBillingCycleDays(subscription.billingCycle)), 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)) { subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려) // 해당 월의 비용 계산 (이벤트 가격 고려)
double cost; double cost;
@@ -334,7 +338,8 @@ class SubscriptionProvider extends ChangeNotifier {
subscription.eventStartDate != null && subscription.eventStartDate != null &&
subscription.eventEndDate != 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)) { subscription.eventEndDate!.isAfter(month)) {
cost = subscription.eventPrice ?? subscription.monthlyCost; cost = subscription.eventPrice ?? subscription.monthlyCost;
} else { } else {
@@ -342,7 +347,8 @@ class SubscriptionProvider extends ChangeNotifier {
} }
// 통화 변환 // 통화 변환
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost, cost,
subscription.currency, subscription.currency,
targetCurrency, targetCurrency,
@@ -354,7 +360,8 @@ class SubscriptionProvider extends ChangeNotifier {
} }
if (isCurrentMonth) { if (isCurrentMonth) {
debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency'); debugPrint(
'[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
} }
monthlyData.add({ monthlyData.add({
@@ -431,10 +438,12 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('티빙') || serviceName.contains('티빙') ||
serviceName.contains('디즈니') || serviceName.contains('디즈니') ||
serviceName.contains('넷플릭스')) { serviceName.contains('넷플릭스')) {
categoryId = categories.firstWhere( categoryId = categories
.firstWhere(
(cat) => cat.name == 'OTT 서비스', (cat) => cat.name == 'OTT 서비스',
orElse: () => categories.first, orElse: () => categories.first,
).id; )
.id;
} }
// 음악 서비스 // 음악 서비스
else if (serviceName.contains('spotify') || else if (serviceName.contains('spotify') ||
@@ -443,30 +452,36 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('지니') || serviceName.contains('지니') ||
serviceName.contains('플로') || serviceName.contains('플로') ||
serviceName.contains('벡스')) { serviceName.contains('벡스')) {
categoryId = categories.firstWhere( categoryId = categories
.firstWhere(
(cat) => cat.name == 'music', (cat) => cat.name == 'music',
orElse: () => categories.first, orElse: () => categories.first,
).id; )
.id;
} }
// AI 서비스 // AI 서비스
else if (serviceName.contains('chatgpt') || else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') || serviceName.contains('claude') ||
serviceName.contains('midjourney') || serviceName.contains('midjourney') ||
serviceName.contains('copilot')) { serviceName.contains('copilot')) {
categoryId = categories.firstWhere( categoryId = categories
.firstWhere(
(cat) => cat.name == 'aiService', (cat) => cat.name == 'aiService',
orElse: () => categories.first, orElse: () => categories.first,
).id; )
.id;
} }
// 프로그래밍/개발 // 프로그래밍/개발
else if (serviceName.contains('github') || else if (serviceName.contains('github') ||
serviceName.contains('intellij') || serviceName.contains('intellij') ||
serviceName.contains('webstorm') || serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) { serviceName.contains('jetbrains')) {
categoryId = categories.firstWhere( categoryId = categories
.firstWhere(
(cat) => cat.name == 'programming', (cat) => cat.name == 'programming',
orElse: () => categories.first, orElse: () => categories.first,
).id; )
.id;
} }
// 오피스/협업 툴 // 오피스/협업 툴
else if (serviceName.contains('notion') || else if (serviceName.contains('notion') ||
@@ -476,31 +491,34 @@ class SubscriptionProvider extends ChangeNotifier {
serviceName.contains('figma') || serviceName.contains('figma') ||
serviceName.contains('icloud') || serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) { serviceName.contains('아이클라우드')) {
categoryId = categories.firstWhere( categoryId = categories
.firstWhere(
(cat) => cat.name == 'collaborationOffice', (cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first, orElse: () => categories.first,
).id; )
.id;
} }
// 기타 서비스 (기본값) // 기타 서비스 (기본값)
else { else {
categoryId = categories.firstWhere( categoryId = categories
.firstWhere(
(cat) => cat.name == 'other', (cat) => cat.name == 'other',
orElse: () => categories.first, orElse: () => categories.first,
).id; )
.id;
} }
if (categoryId != null) {
subscription.categoryId = categoryId; subscription.categoryId = categoryId;
await subscription.save(); await subscription.save();
migratedCount++; migratedCount++;
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name; final categoryName =
categories.firstWhere((cat) => cat.id == categoryId).name;
debugPrint('${subscription.serviceName}$categoryName'); debugPrint('${subscription.serviceName}$categoryName');
} }
} }
}
if (migratedCount > 0) { if (migratedCount > 0) {
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료'); debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
await refreshSubscriptions(); await refreshSubscriptions();
} else { } else {
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');

View File

@@ -6,6 +6,7 @@ import 'package:submanager/screens/sms_scan_screen.dart';
import 'package:submanager/screens/analysis_screen.dart'; import 'package:submanager/screens/analysis_screen.dart';
import 'package:submanager/screens/settings_screen.dart'; import 'package:submanager/screens/settings_screen.dart';
import 'package:submanager/screens/splash_screen.dart'; import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart'; import 'package:submanager/models/subscription_model.dart';
class AppRoutes { class AppRoutes {
@@ -16,6 +17,7 @@ class AppRoutes {
static const String smsScanner = '/sms-scanner'; static const String smsScanner = '/sms-scanner';
static const String analysis = '/analysis'; static const String analysis = '/analysis';
static const String settings = '/settings'; static const String settings = '/settings';
static const String smsPermission = '/sms-permission';
static Map<String, WidgetBuilder> getRoutes() { static Map<String, WidgetBuilder> getRoutes() {
return { return {
@@ -25,6 +27,7 @@ class AppRoutes {
smsScanner: (context) => const SmsScanScreen(), smsScanner: (context) => const SmsScanScreen(),
analysis: (context) => const AnalysisScreen(), analysis: (context) => const AnalysisScreen(),
settings: (context) => const SettingsScreen(), settings: (context) => const SettingsScreen(),
smsPermission: (context) => const SmsPermissionScreen(),
}; };
} }
@@ -42,7 +45,8 @@ class AppRoutes {
case subscriptionDetail: case subscriptionDetail:
final subscription = routeSettings.arguments as SubscriptionModel?; final subscription = routeSettings.arguments as SubscriptionModel?;
if (subscription != null) { if (subscription != null) {
return _buildRoute(DetailScreen(subscription: subscription), routeSettings); return _buildRoute(
DetailScreen(subscription: subscription), routeSettings);
} }
return _errorRoute(); return _errorRoute();
@@ -55,6 +59,9 @@ class AppRoutes {
case settings: case settings:
return _buildRoute(const SettingsScreen(), routeSettings); return _buildRoute(const SettingsScreen(), routeSettings);
case smsPermission:
return _buildRoute(const SmsPermissionScreen(), routeSettings);
default: default:
return _errorRoute(); 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); 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); 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( Navigator.pushNamedAndRemoveUntil(
context, context,
routeName, routeName,

View File

@@ -48,7 +48,9 @@ class _AnalysisScreenState extends State<AnalysisScreen>
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading'); '현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드 // 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) { if (currentHash != _lastDataHash &&
!_isLoading &&
_lastDataHash.isNotEmpty) {
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작'); debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
_loadData(); _loadData();
} }
@@ -71,7 +73,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2)); buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
for (final sub in subscriptions) { 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(); return buffer.toString();
@@ -166,7 +169,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 2. 총 지출 요약 카드 // 2. 총 지출 요약 카드
TotalExpenseSummaryCard( TotalExpenseSummaryCard(
key: ValueKey('total_expense_${_lastDataHash}'), key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions, subscriptions: subscriptions,
totalExpense: _totalExpense, totalExpense: _totalExpense,
animationController: _animationController, animationController: _animationController,
@@ -176,7 +179,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// 3. 월별 지출 차트 // 3. 월별 지출 차트
MonthlyExpenseChartCard( MonthlyExpenseChartCard(
key: ValueKey('monthly_expense_${_lastDataHash}'), key: ValueKey('monthly_expense_$_lastDataHash'),
monthlyData: _monthlyData, monthlyData: _monthlyData,
animationController: _animationController, animationController: _animationController,
), ),

View File

@@ -13,13 +13,13 @@ class AppLockScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.lock_outline, Icons.lock_outline,
size: 80, size: 80,
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( const Text(
'앱이 잠겨 있습니다', '앱이 잠겨 있습니다',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
@@ -28,7 +28,7 @@ class AppLockScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( const Text(
'생체 인증으로 잠금을 해제하세요', '생체 인증으로 잠금을 해제하세요',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@@ -42,7 +42,7 @@ class AppLockScreen extends StatelessWidget {
final success = await appLock.authenticate(); final success = await appLock.authenticate();
if (!success && context.mounted) { if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text( content: Text(
'인증에 실패했습니다. 다시 시도해주세요.', '인증에 실패했습니다. 다시 시도해주세요.',
style: TextStyle( style: TextStyle(

View File

@@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: const Text(
'카테고리 관리', '카테고리 관리',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '카테고리 이름', labelText: '카테고리 이름',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedColor, value: _selectedColor,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '색상 선택', labelText: '색상 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
@@ -90,15 +90,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
), ),
items: [ items: [
DropdownMenuItem( 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( 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( 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( 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( 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) { onChanged: (value) {
setState(() { setState(() {
@@ -109,22 +129,38 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedIcon, value: _selectedIcon,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: '아이콘 선택', labelText: '아이콘 선택',
labelStyle: TextStyle( labelStyle: TextStyle(
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
), ),
items: [ items: const [
DropdownMenuItem( DropdownMenuItem(
value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))), value: 'subscriptions',
DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))), child: Text('구독',
style:
TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))), value: 'movie',
child: Text('영화',
style:
TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))), value: 'music_note',
child: Text('음악',
style:
TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( 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) { onChanged: (value) {
setState(() { setState(() {
@@ -135,7 +171,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _addCategory, onPressed: _addCategory,
child: Text( child: const Text(
'카테고리 추가', '카테고리 추가',
style: TextStyle( style: TextStyle(
color: AppColors.pureWhite, color: AppColors.pureWhite,
@@ -163,8 +199,9 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
int.parse(category.color.replaceAll('#', '0xFF'))), int.parse(category.color.replaceAll('#', '0xFF'))),
), ),
title: Text( title: Text(
provider.getLocalizedCategoryName(context, category.name), provider.getLocalizedCategoryName(
style: TextStyle( context, category.name),
style: const TextStyle(
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),
), ),

View File

@@ -43,7 +43,6 @@ class _DetailScreenState extends State<DetailScreen>
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final baseColor = _controller.getCardColor(); final baseColor = _controller.getCardColor();
@@ -110,8 +109,9 @@ class _DetailScreenState extends State<DetailScreen>
), ),
const Spacer(), const Spacer(),
Text( Text(
AppLocalizations.of(context).changesAppliedAfterSave, AppLocalizations.of(context)
style: TextStyle( .changesAppliedAfterSave,
style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),

View File

@@ -236,7 +236,11 @@ class _MainScreenState extends State<MainScreen>
body: IndexedStack( body: IndexedStack(
index: PlatformHelper.isIOS index: PlatformHelper.isIOS
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
: (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직 : (currentIndex == 3
? 3
: currentIndex == 4
? 4
: currentIndex), // Android: 기존 로직
children: _screens, children: _screens,
), ),
backgroundGradient: backgroundGradient, backgroundGradient: backgroundGradient,

View File

@@ -5,13 +5,14 @@ import '../providers/notification_provider.dart';
import 'dart:io'; import 'dart:io';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../theme/adaptive_theme.dart';
import '../widgets/glassmorphism_card.dart'; import '../widgets/glassmorphism_card.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import '../services/sms_service.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -228,6 +229,7 @@ class SettingsScreen extends StatelessWidget {
if (granted) { if (granted) {
await provider.setEnabled(true); await provider.setEnabled(true);
} else { } else {
if (!context.mounted) return;
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context) message: AppLocalizations.of(context)
@@ -271,7 +273,7 @@ class SettingsScreen extends StatelessWidget {
elevation: 0, elevation: 0,
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceVariant .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -412,7 +414,7 @@ class SettingsScreen extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceVariant .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
borderRadius: borderRadius:
BorderRadius.circular(8), 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<permission.PermissionStatus>(
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( GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),

View File

@@ -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<SmsPermissionScreen> createState() => _SmsPermissionScreenState();
}
class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
bool _requesting = false;
Future<void> _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),
)
],
),
),
),
),
);
}
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../controllers/sms_scan_controller.dart'; import '../controllers/sms_scan_controller.dart';
import '../widgets/sms_scan/scan_loading_widget.dart'; import '../widgets/sms_scan/scan_loading_widget.dart';
import '../widgets/sms_scan/scan_initial_widget.dart'; import '../widgets/sms_scan/scan_initial_widget.dart';
@@ -75,7 +74,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
); );
} }
final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex]; final currentSubscription =
_controller.scannedSubscriptions[_controller.currentIndex];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../services/sms_service.dart';
import '../utils/platform_helper.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -98,9 +100,20 @@ class _SplashScreenState extends State<SplashScreen>
} }
} }
void navigateToNextScreen() { Future<void> navigateToNextScreen() async {
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동 // 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( Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main, AppRoutes.main,
(route) => false, (route) => false,
@@ -264,13 +277,13 @@ class _SplashScreenState extends State<SplashScreen>
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
width: 1.5, width: 1.5,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: color:
AppColors.shadowBlack, AppColors.shadowBlack,
spreadRadius: 0, spreadRadius: 0,
blurRadius: 30, blurRadius: 30,
offset: const Offset(0, 10), offset: Offset(0, 10),
), ),
], ],
), ),
@@ -385,7 +398,7 @@ class _SplashScreenState extends State<SplashScreen>
width: 1, width: 1,
), ),
), ),
child: CircularProgressIndicator( child: const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.pureWhite), AppColors.pureWhite),
strokeWidth: 3, strokeWidth: 3,

View File

@@ -89,9 +89,11 @@ class CurrencyUtil {
// USD 입력인 경우 - 기본 통화로 변환하여 표시 // USD 입력인 경우 - 기본 통화로 변환하여 표시
if (currency == 'USD' && defaultCurrency != 'USD') { if (currency == 'USD' && defaultCurrency != 'USD') {
final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency); final convertedAmount = await _exchangeRateService.convertUsdToTarget(
amount, defaultCurrency);
if (convertedAmount != null) { if (convertedAmount != null) {
final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency); final primaryFormatted =
_formatSingleCurrency(convertedAmount, defaultCurrency);
final usdFormatted = _formatSingleCurrency(amount, 'USD'); final usdFormatted = _formatSingleCurrency(amount, 'USD');
return '$primaryFormatted ($usdFormatted)'; return '$primaryFormatted ($usdFormatted)';
} }

View File

@@ -1,6 +1,7 @@
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert'; import 'dart:convert';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../utils/logger.dart';
/// 환율 정보 서비스 클래스 /// 환율 정보 서비스 클래스
class ExchangeRateService { class ExchangeRateService {
@@ -21,12 +22,20 @@ class ExchangeRateService {
double? _usdToCnyRate; double? _usdToCnyRate;
DateTime? _lastUpdated; DateTime? _lastUpdated;
// API 요청 URL (ExchangeRate-API 사용) // API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD'; 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; static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_JPY_RATE = 150.0; static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
// ignore: constant_identifier_names
static const double DEFAULT_USD_TO_CNY_RATE = 7.2; static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
// 캐싱된 환율 반환 (동기적) // 캐싱된 환율 반환 (동기적)
@@ -44,18 +53,26 @@ class ExchangeRateService {
} }
try { try {
// API 요청 // API 요청 (네트워크 불가 환경에서는 예외 발생 가능)
final response = await http.get(Uri.parse(_apiUrl)); final response = await http.get(Uri.parse(_apiUrl));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
_usdToKrwRate = data['rates']['KRW']?.toDouble(); _usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble();
_usdToJpyRate = data['rates']['JPY']?.toDouble(); _usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
_usdToCnyRate = data['rates']['CNY']?.toDouble(); _usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
_lastUpdated = DateTime.now(); _lastUpdated = DateTime.now();
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,7 +92,8 @@ class ExchangeRateService {
} }
/// USD 금액을 지정된 통화로 변환합니다. /// USD 금액을 지정된 통화로 변환합니다.
Future<double?> convertUsdToTarget(double usdAmount, String targetCurrency) async { Future<double?> convertUsdToTarget(
double usdAmount, String targetCurrency) async {
await _fetchAllRatesIfNeeded(); await _fetchAllRatesIfNeeded();
switch (targetCurrency) { switch (targetCurrency) {
@@ -96,7 +114,8 @@ class ExchangeRateService {
} }
/// 지정된 통화를 USD로 변환합니다. /// 지정된 통화를 USD로 변환합니다.
Future<double?> convertTargetToUsd(double amount, String sourceCurrency) async { Future<double?> convertTargetToUsd(
double amount, String sourceCurrency) async {
await _fetchAllRatesIfNeeded(); await _fetchAllRatesIfNeeded();
switch (sourceCurrency) { switch (sourceCurrency) {
@@ -118,10 +137,7 @@ class ExchangeRateService {
/// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환) /// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환)
Future<double?> convertBetweenCurrencies( Future<double?> convertBetweenCurrencies(
double amount, double amount, String fromCurrency, String toCurrency) async {
String fromCurrency,
String toCurrency
) async {
if (fromCurrency == toCurrency) { if (fromCurrency == toCurrency) {
return amount; return amount;
} }
@@ -204,7 +220,8 @@ class ExchangeRateService {
} }
/// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다. /// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다.
Future<String> getFormattedAmountForLocale(double usdAmount, String locale) async { Future<String> getFormattedAmountForLocale(
double usdAmount, String locale) async {
String targetCurrency; String targetCurrency;
String localeCode; String localeCode;
String symbol; String symbol;

View File

@@ -1,7 +1,6 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
@@ -10,7 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class NotificationService { class NotificationService {
static final FlutterLocalNotificationsPlugin _notifications = static final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
static final _secureStorage = const FlutterSecureStorage(); static const _secureStorage = FlutterSecureStorage();
static const _notificationEnabledKey = 'notification_enabled'; static const _notificationEnabledKey = 'notification_enabled';
static const _paymentNotificationEnabledKey = 'payment_notification_enabled'; static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
@@ -150,15 +149,14 @@ class NotificationService {
} }
} }
static Future<bool> requestPermission() async { static Future<bool> requestPermission() async {
// 웹 플랫폼인 경우 false 반환 // 웹 플랫폼인 경우 false 반환
if (_isWeb) return false; if (_isWeb) return false;
// iOS 처리 // iOS 처리
if (Platform.isIOS) { if (Platform.isIOS) {
final iosImplementation = _notifications final iosImplementation =
.resolvePlatformSpecificImplementation< _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>(); IOSFlutterLocalNotificationsPlugin>();
if (iosImplementation != null) { if (iosImplementation != null) {
@@ -173,13 +171,13 @@ class NotificationService {
// Android 처리 // Android 처리
if (Platform.isAndroid) { if (Platform.isAndroid) {
final androidImplementation = _notifications final androidImplementation =
.resolvePlatformSpecificImplementation< _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>(); AndroidFlutterLocalNotificationsPlugin>();
if (androidImplementation != null) { if (androidImplementation != null) {
final granted = await androidImplementation final granted =
.requestNotificationsPermission(); await androidImplementation.requestNotificationsPermission();
return granted ?? false; return granted ?? false;
} }
} }
@@ -194,8 +192,8 @@ class NotificationService {
// Android 처리 // Android 처리
if (Platform.isAndroid) { if (Platform.isAndroid) {
final androidImplementation = _notifications final androidImplementation =
.resolvePlatformSpecificImplementation< _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>(); AndroidFlutterLocalNotificationsPlugin>();
if (androidImplementation != null) { if (androidImplementation != null) {
@@ -207,8 +205,8 @@ class NotificationService {
// iOS 처리 // iOS 처리
if (Platform.isIOS) { if (Platform.isIOS) {
final iosImplementation = _notifications final iosImplementation =
.resolvePlatformSpecificImplementation< _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>(); IOSFlutterLocalNotificationsPlugin>();
if (iosImplementation != null) { if (iosImplementation != null) {
@@ -242,7 +240,7 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
); );
final iosDetails = const DarwinNotificationDetails(); const iosDetails = DarwinNotificationDetails();
// tz.local 초기화 확인 및 재시도 // tz.local 초기화 확인 및 재시도
tz.Location location; tz.Location location;
@@ -267,10 +265,10 @@ class NotificationService {
title, title,
body, body,
tz.TZDateTime.from(scheduledDate, location), tz.TZDateTime.from(scheduledDate, location),
NotificationDetails(android: androidDetails, iOS: iosDetails), const NotificationDetails(android: androidDetails, iOS: iosDetails),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('알림 예약 중 오류 발생: $e'); debugPrint('알림 예약 중 오류 발생: $e');
@@ -352,9 +350,9 @@ class NotificationService {
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.', '${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
tz.TZDateTime.from(subscription.nextBillingDate, location), tz.TZDateTime.from(subscription.nextBillingDate, location),
notificationDetails, notificationDetails,
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('구독 알림 예약 중 오류 발생: $e'); debugPrint('구독 알림 예약 중 오류 발생: $e');
@@ -417,9 +415,9 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
), ),
), ),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('결제 알림 예약 중 오류 발생: $e'); debugPrint('결제 알림 예약 중 오류 발생: $e');
@@ -457,7 +455,7 @@ class NotificationService {
} }
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
(subscription.id + '_expiration').hashCode, ('${subscription.id}_expiration').hashCode,
'구독 만료 예정 알림', '구독 만료 예정 알림',
'${subscription.serviceName} 구독이 7일 후 만료됩니다.', '${subscription.serviceName} 구독이 7일 후 만료됩니다.',
tz.TZDateTime.from(reminderDate, location), tz.TZDateTime.from(reminderDate, location),
@@ -470,9 +468,9 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
), ),
), ),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation: uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime, UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
); );
} catch (e) { } catch (e) {
debugPrint('만료 알림 예약 중 오류 발생: $e'); debugPrint('만료 알림 예약 중 오류 발생: $e');
@@ -512,8 +510,9 @@ class NotificationService {
} }
// 기본 알림 예약 (지정된 일수 전) // 기본 알림 예약 (지정된 일수 전)
final scheduledDate = final scheduledDate = subscription.nextBillingDate
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith( .subtract(Duration(days: reminderDays))
.copyWith(
hour: reminderHour, hour: reminderHour,
minute: reminderMinute, minute: reminderMinute,
second: 0, second: 0,
@@ -536,12 +535,14 @@ class NotificationService {
// 이벤트가 결제일 전에 종료되는 경우 // 이벤트가 결제일 전에 종료되는 경우
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost; final normalPrice = subscription.monthlyCost;
notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' notificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else { } else {
// 일반 알림 // 일반 알림
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; notificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
} }
await _notifications.zonedSchedule( await _notifications.zonedSchedule(
@@ -568,7 +569,8 @@ class NotificationService {
if (isDailyReminder && reminderDays >= 2) { if (isDailyReminder && reminderDays >= 2) {
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
for (int i = reminderDays - 1; i >= 1; i--) { for (int i = reminderDays - 1; i >= 1; i--) {
final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( final dailyDate =
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
hour: reminderHour, hour: reminderHour,
minute: reminderMinute, minute: reminderMinute,
second: 0, second: 0,
@@ -586,15 +588,19 @@ class NotificationService {
String dailyNotificationBody; String dailyNotificationBody;
if (subscription.isEventActive && if (subscription.isEventActive &&
subscription.eventEndDate != null && subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && subscription.eventEndDate!
.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) { subscription.eventEndDate!.isAfter(DateTime.now())) {
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; final eventPrice =
subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost; final normalPrice = subscription.monthlyCost;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' dailyNotificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else { } else {
final currentPrice = subscription.currentPrice; final currentPrice = subscription.currentPrice;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; dailyNotificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
} }
await _notifications.zonedSchedule( await _notifications.zonedSchedule(

View File

@@ -3,7 +3,8 @@ import '../../models/subscription_model.dart';
class SubscriptionConverter { class SubscriptionConverter {
// SubscriptionModel 리스트를 Subscription 리스트로 변환 // SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> convertModelsToSubscriptions(List<SubscriptionModel> models) { List<Subscription> convertModelsToSubscriptions(
List<SubscriptionModel> models) {
final result = <Subscription>[]; final result = <Subscription>[];
for (var model in models) { for (var model in models) {
@@ -11,8 +12,12 @@ class SubscriptionConverter {
final subscription = _convertSingle(model); final subscription = _convertSingle(model);
result.add(subscription); 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) { } catch (e) {
// ignore: avoid_print
print('모델 변환 중 오류 발생: $e'); print('모델 변환 중 오류 발생: $e');
} }
} }

View File

@@ -1,11 +1,13 @@
import '../../models/subscription.dart'; import '../../models/subscription.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../utils/logger.dart';
class SubscriptionFilter { class SubscriptionFilter {
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주) // 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> filterDuplicates( List<Subscription> filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) { List<Subscription> scanned, List<SubscriptionModel> existing) {
print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}'); Log.d(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링 // 중복되지 않은 구독만 필터링
return scanned.where((scannedSub) { return scanned.where((scannedSub) {
@@ -16,7 +18,8 @@ class SubscriptionFilter {
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
if (isSameName && isSameCost) { if (isSameName && isSameCost) {
print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)'); Log.d(
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
return true; return true;
} }
return false; return false;
@@ -27,7 +30,8 @@ class SubscriptionFilter {
} }
// 반복 횟수 기반 필터링 // 반복 횟수 기반 필터링
List<Subscription> filterByRepeatCount(List<Subscription> subscriptions, int minCount) { List<Subscription> filterByRepeatCount(
List<Subscription> subscriptions, int minCount) {
return subscriptions.where((sub) => sub.repeatCount >= minCount).toList(); return subscriptions.where((sub) => sub.repeatCount >= minCount).toList();
} }
@@ -44,7 +48,8 @@ class SubscriptionFilter {
List<Subscription> filterByPriceRange( List<Subscription> filterByPriceRange(
List<Subscription> subscriptions, double minPrice, double maxPrice) { List<Subscription> subscriptions, double minPrice, double maxPrice) {
return subscriptions return subscriptions
.where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice) .where(
(sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
.toList(); .toList();
} }

View File

@@ -1,36 +1,69 @@
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/logger.dart';
import '../temp/test_sms_data.dart'; import '../temp/test_sms_data.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
class SmsScanner { class SmsScanner {
// 반복 사용되는 리소스 상수화로 파싱 성능 최적화
static const List<String> _subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
static final List<RegExp> _amountPatterns = <RegExp>[
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 SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async { Future<List<SubscriptionModel>> scanForSubscriptions() async {
try { try {
List<dynamic> smsList; List<dynamic> smsList;
print('SmsScanner: 스캔 시작'); Log.d('SmsScanner: 스캔 시작');
// 플랫폼별 분기 처리 // 플랫폼별 분기 처리
if (kIsWeb) { if (kIsWeb) {
// 웹 환경: 테스트 데이터 사용 // 웹 환경: 테스트 데이터 사용
print('SmsScanner: 웹 환경에서 테스트 데이터 사용'); Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용');
smsList = TestSmsData.getTestData(); smsList = TestSmsData.getTestData();
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
} else if (PlatformHelper.isIOS) { } else if (PlatformHelper.isIOS) {
// iOS: SMS 접근 불가, 빈 리스트 반환 // iOS: SMS 접근 불가, 빈 리스트 반환
print('SmsScanner: iOS에서는 SMS 스캔 불가'); Log.w('SmsScanner: iOS에서는 SMS 스캔 불가');
return []; return [];
} else if (PlatformHelper.isAndroid) { } else if (PlatformHelper.isAndroid) {
// Android: flutter_sms_inbox 사용 // Android: flutter_sms_inbox 사용
print('SmsScanner: Android에서 실제 SMS 스캔'); Log.i('SmsScanner: Android에서 실제 SMS 스캔');
smsList = await _scanAndroidSms(); smsList = await _scanAndroidSms();
print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}');
} else { } else {
// 기타 플랫폼 // 기타 플랫폼
print('SmsScanner: 지원하지 않는 플랫폼'); Log.w('SmsScanner: 지원하지 않는 플랫폼');
return []; return [];
} }
@@ -47,32 +80,32 @@ class SmsScanner {
serviceGroups[serviceName]!.add(sms); serviceGroups[serviceName]!.add(sms);
} }
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
// 그룹화된 데이터로 구독 분석 // 그룹화된 데이터로 구독 분석
for (final entry in serviceGroups.entries) { for (final entry in serviceGroups.entries) {
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
// 2회 이상 반복된 서비스만 구독으로 간주 // 2회 이상 반복된 서비스만 구독으로 간주
if (entry.value.length >= 2) { if (entry.value.length >= 2) {
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
final subscription = _parseSms(serviceSms, entry.value.length); final subscription = _parseSms(serviceSms, entry.value.length);
if (subscription != null) { if (subscription != null) {
print( Log.i(
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
subscriptions.add(subscription); subscriptions.add(subscription);
} else { } else {
print('SmsScanner: 구독 파싱 실패: ${entry.key}'); Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
} }
} else { } else {
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
} }
} }
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
return subscriptions; return subscriptions;
} catch (e) { } catch (e) {
print('SmsScanner: 예외 발생: $e'); Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $e'); throw Exception('SMS 스캔 중 오류 발생: $e');
} }
} }
@@ -93,7 +126,7 @@ class SmsScanner {
return smsList; return smsList;
} catch (e) { } catch (e) {
print('SmsScanner: Android SMS 스캔 실패: $e'); Log.e('SmsScanner: Android SMS 스캔 실패', e);
return []; return [];
} }
} }
@@ -105,20 +138,10 @@ class SmsScanner {
final sender = message.address ?? ''; final sender = message.address ?? '';
final date = message.date ?? DateTime.now(); 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) => bool isSubscription = _subscriptionKeywords.any((keyword) =>
body.toLowerCase().contains(keyword.toLowerCase()) || body.toLowerCase().contains(keyword.toLowerCase()) ||
sender.toLowerCase().contains(keyword.toLowerCase()) sender.toLowerCase().contains(keyword.toLowerCase()));
);
if (!isSubscription) { if (!isSubscription) {
return null; return null;
@@ -138,11 +161,12 @@ class SmsScanner {
'monthlyCost': amount ?? 0.0, 'monthlyCost': amount ?? 0.0,
'billingCycle': billingCycle, 'billingCycle': billingCycle,
'message': body, 'message': body,
'nextBillingDate': _calculateNextBillingFromDate(date, billingCycle).toIso8601String(), 'nextBillingDate':
_calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
'previousPaymentDate': date.toIso8601String(), 'previousPaymentDate': date.toIso8601String(),
}; };
} catch (e) { } catch (e) {
print('SmsScanner: SMS 파싱 실패: $e'); Log.e('SmsScanner: SMS 파싱 실패', e);
return null; return null;
} }
} }
@@ -189,15 +213,8 @@ class SmsScanner {
// 금액 추출 로직 // 금액 추출 로직
double? _extractAmount(String body) { double? _extractAmount(String body) {
// 다양한 금액 패턴 매칭 // 다양한 금액 패턴 매칭(사전 컴파일)
final patterns = [ for (final pattern in _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})*)'), // 결제 금액
];
for (final pattern in patterns) {
final match = pattern.firstMatch(body); final match = pattern.firstMatch(body);
if (match != null) { if (match != null) {
String amountStr = match.group(1) ?? ''; String amountStr = match.group(1) ?? '';
@@ -213,7 +230,9 @@ class SmsScanner {
String _extractBillingCycle(String body) { String _extractBillingCycle(String body) {
if (body.contains('') || body.contains('monthly') || body.contains('매월')) { if (body.contains('') || body.contains('monthly') || body.contains('매월')) {
return 'monthly'; return 'monthly';
} else if (body.contains('') || body.contains('yearly') || body.contains('annual')) { } else if (body.contains('') ||
body.contains('yearly') ||
body.contains('annual')) {
return 'yearly'; return 'yearly';
} else if (body.contains('') || body.contains('weekly')) { } else if (body.contains('') || body.contains('weekly')) {
return 'weekly'; return 'weekly';
@@ -224,7 +243,8 @@ class SmsScanner {
} }
// 다음 결제일 계산 // 다음 결제일 계산
DateTime _calculateNextBillingFromDate(DateTime lastDate, String billingCycle) { DateTime _calculateNextBillingFromDate(
DateTime lastDate, String billingCycle) {
switch (billingCycle) { switch (billingCycle) {
case 'monthly': case 'monthly':
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
@@ -241,7 +261,8 @@ class SmsScanner {
try { try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; 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 nextBillingDateStr = sms['nextBillingDate'] as String?;
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
@@ -259,7 +280,7 @@ class SmsScanner {
'Spotify Premium' 'Spotify Premium'
]; ];
if (dollarServices.any((service) => serviceName.contains(service))) { if (dollarServices.any((service) => serviceName.contains(service))) {
print('서비스명 $serviceName으로 USD 통화 단위 확정'); Log.d('서비스명 $serviceName으로 USD 통화 단위 확정');
currency = 'USD'; currency = 'USD';
} }
@@ -369,8 +390,6 @@ class SmsScanner {
return serviceUrls[serviceName]; return serviceUrls[serviceName];
} }
// 메시지에서 통화 단위를 감지하는 함수 // 메시지에서 통화 단위를 감지하는 함수
String _detectCurrency(String message) { String _detectCurrency(String message) {
final dollarKeywords = [ final dollarKeywords = [
@@ -391,7 +410,7 @@ class SmsScanner {
// 서비스명 기반 통화 단위 확인 // 서비스명 기반 통화 단위 확인
for (final service in serviceCurrencyMap.keys) { for (final service in serviceCurrencyMap.keys) {
if (message.contains(service)) { if (message.contains(service)) {
print('_detectCurrency: ${service} USD 서비스로 판별됨'); Log.d('_detectCurrency: $service USD 서비스로 판별됨');
return 'USD'; return 'USD';
} }
} }
@@ -399,7 +418,7 @@ class SmsScanner {
// 메시지에 달러 관련 키워드가 있는지 확인 // 메시지에 달러 관련 키워드가 있는지 확인
for (final keyword in dollarKeywords) { for (final keyword in dollarKeywords) {
if (message.toLowerCase().contains(keyword.toLowerCase())) { if (message.toLowerCase().contains(keyword.toLowerCase())) {
print('_detectCurrency: USD 키워드 발견: $keyword'); Log.d('_detectCurrency: USD 키워드 발견: $keyword');
return 'USD'; return 'USD';
} }
} }

View File

@@ -29,7 +29,8 @@ class SubscriptionUrlMatcher {
// 2. 서비스 초기화 // 2. 서비스 초기화
_categoryMapper = CategoryMapperService(_dataRepository!); _categoryMapper = CategoryMapperService(_dataRepository!);
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!); _urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!); _cancellationService =
CancellationUrlService(_dataRepository!, _urlMatcher!);
_nameResolver = ServiceNameResolver(_dataRepository!); _nameResolver = ServiceNameResolver(_dataRepository!);
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!); _smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
} }
@@ -67,7 +68,8 @@ class SubscriptionUrlMatcher {
/// 서비스에 공식 해지 안내 페이지가 있는지 확인 /// 서비스에 공식 해지 안내 페이지가 있는지 확인
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async { static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
await initialize(); await initialize();
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false; return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ??
false;
} }
/// 서비스명으로 카테고리 찾기 /// 서비스명으로 카테고리 찾기
@@ -85,7 +87,8 @@ class SubscriptionUrlMatcher {
return await _nameResolver?.getServiceDisplayName( return await _nameResolver?.getServiceDisplayName(
serviceName: serviceName, serviceName: serviceName,
locale: locale, locale: locale,
) ?? serviceName; ) ??
serviceName;
} }
/// SMS에서 URL과 서비스 정보 추출 /// SMS에서 URL과 서비스 정보 추출

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../utils/logger.dart';
/// 서비스 데이터를 관리하는 저장소 클래스 /// 서비스 데이터를 관리하는 저장소 클래스
class ServiceDataRepository { class ServiceDataRepository {
@@ -11,12 +12,13 @@ class ServiceDataRepository {
if (_isInitialized) return; if (_isInitialized) return;
try { 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); _servicesData = json.decode(jsonString);
_isInitialized = true; _isInitialized = true;
print('ServiceDataRepository: JSON 데이터 로드 완료'); Log.i('ServiceDataRepository: JSON 데이터 로드 완료');
} catch (e) { } catch (e) {
print('ServiceDataRepository: JSON 로드 실패 - $e'); Log.w('ServiceDataRepository: JSON 로드 실패 - $e');
// 로드 실패시 기존 하드코딩 데이터 사용 // 로드 실패시 기존 하드코딩 데이터 사용
_isInitialized = true; _isInitialized = true;
} }

View File

@@ -25,14 +25,18 @@ class CancellationUrlService {
final lowerName = serviceName.toLowerCase().trim(); final lowerName = serviceName.toLowerCase().trim();
for (final categoryData in categories.values) { for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>; final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []); final names = List<String>.from(
(serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in names) { for (final name in names) {
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { if (lowerName.contains(name.toLowerCase()) ||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?; name.toLowerCase().contains(lowerName)) {
final cancellationUrls =
serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) { if (cancellationUrls != null) {
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환 // 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
return cancellationUrls[locale] ?? return cancellationUrls[locale] ??
@@ -49,14 +53,18 @@ class CancellationUrlService {
final domain = _urlMatcher.extractDomain(websiteUrl); final domain = _urlMatcher.extractDomain(websiteUrl);
if (domain != null) { if (domain != null) {
for (final categoryData in categories.values) { for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>; final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []); final domains = List<String>.from(
(serviceData as Map<String, dynamic>)['domains'] ?? []);
for (final serviceDomain in domains) { for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { if (domain.contains(serviceDomain) ||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?; serviceDomain.contains(domain)) {
final cancellationUrls =
serviceData['cancellationUrls'] as Map<String, dynamic>?;
if (cancellationUrls != null) { if (cancellationUrls != null) {
return cancellationUrls[locale] ?? return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr']; cancellationUrls[locale == 'kr' ? 'en' : 'kr'];

View File

@@ -24,10 +24,12 @@ class CategoryMapperService {
final services = categoryData['services'] as Map<String, dynamic>; final services = categoryData['services'] as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []); final names = List<String>.from(
(serviceData as Map<String, dynamic>)['names'] ?? []);
for (final name in 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); return getCategoryIdByKey(categoryId);
} }
} }
@@ -73,15 +75,33 @@ class CategoryMapperService {
String getCategoryForLegacyService(String serviceName) { String getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase(); final lowerName = serviceName.toLowerCase();
if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; if (LegacyServiceData.ottServices.containsKey(lowerName)) {
if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; return 'ott_services';
if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; }
if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; if (LegacyServiceData.musicServices.containsKey(lowerName)) {
if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; return 'music_streaming';
if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; }
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; if (LegacyServiceData.storageServices.containsKey(lowerName)) {
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; return 'cloud_storage';
if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; }
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'; return 'other';
} }

View File

@@ -21,7 +21,8 @@ class ServiceNameResolver {
// JSON에서 서비스 찾기 // JSON에서 서비스 찾기
for (final categoryData in categories.values) { for (final categoryData in categories.values) {
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>; final services = (categoryData as Map<String, dynamic>)['services']
as Map<String, dynamic>;
for (final serviceData in services.values) { for (final serviceData in services.values) {
final data = serviceData as Map<String, dynamic>; final data = serviceData as Map<String, dynamic>;

View File

@@ -36,7 +36,9 @@ class SmsExtractorService {
// 모든 서비스명 검사 // 모든 서비스명 검사
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
if (lowerSms.contains(entry.key.toLowerCase())) { if (lowerSms.contains(entry.key.toLowerCase())) {
final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other'; final categoryId =
await _categoryMapper.findCategoryByServiceName(entry.key) ??
'other';
return ServiceInfo( return ServiceInfo(
serviceId: entry.key, serviceId: entry.key,

View File

@@ -2,6 +2,7 @@ import '../models/service_info.dart';
import '../data/service_data_repository.dart'; import '../data/service_data_repository.dart';
import '../data/legacy_service_data.dart'; import '../data/legacy_service_data.dart';
import 'category_mapper_service.dart'; import 'category_mapper_service.dart';
import '../../../utils/logger.dart';
/// URL 매칭 관련 기능을 제공하는 서비스 클래스 /// URL 매칭 관련 기능을 제공하는 서비스 클래스
class UrlMatcherService { class UrlMatcherService {
@@ -35,7 +36,7 @@ class UrlMatcherService {
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: 도메인 추출 실패 - $e'); Log.e('UrlMatcherService: 도메인 추출 실패', e);
return null; return null;
} }
} }
@@ -62,7 +63,8 @@ class UrlMatcherService {
// 도메인이 일치하는지 확인 // 도메인이 일치하는지 확인
for (final serviceDomain in domains) { for (final serviceDomain in domains) {
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { if (domain.contains(serviceDomain) ||
serviceDomain.contains(domain)) {
final names = List<String>.from(serviceData['names'] ?? []); final names = List<String>.from(serviceData['names'] ?? []);
final urls = serviceData['urls'] as Map<String, dynamic>?; final urls = serviceData['urls'] as Map<String, dynamic>?;
@@ -106,7 +108,7 @@ class UrlMatcherService {
/// 서비스명으로 URL 찾기 /// 서비스명으로 URL 찾기
String? suggestUrl(String serviceName) { String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) { if (serviceName.isEmpty) {
print('UrlMatcherService: 빈 serviceName'); Log.w('UrlMatcherService: 빈 serviceName');
return null; return null;
} }
@@ -117,7 +119,7 @@ class UrlMatcherService {
// 정확한 매칭을 먼저 시도 // 정확한 매칭을 먼저 시도
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -125,7 +127,7 @@ class UrlMatcherService {
// OTT 서비스 검사 // OTT 서비스 검사
for (final entry in LegacyServiceData.ottServices.entries) { for (final entry in LegacyServiceData.ottServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -133,7 +135,7 @@ class UrlMatcherService {
// 음악 서비스 검사 // 음악 서비스 검사
for (final entry in LegacyServiceData.musicServices.entries) { for (final entry in LegacyServiceData.musicServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -141,7 +143,7 @@ class UrlMatcherService {
// AI 서비스 검사 // AI 서비스 검사
for (final entry in LegacyServiceData.aiServices.entries) { for (final entry in LegacyServiceData.aiServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -149,7 +151,7 @@ class UrlMatcherService {
// 프로그래밍 서비스 검사 // 프로그래밍 서비스 검사
for (final entry in LegacyServiceData.programmingServices.entries) { for (final entry in LegacyServiceData.programmingServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -157,7 +159,7 @@ class UrlMatcherService {
// 오피스 툴 검사 // 오피스 툴 검사
for (final entry in LegacyServiceData.officeTools.entries) { for (final entry in LegacyServiceData.officeTools.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -165,7 +167,7 @@ class UrlMatcherService {
// 기타 서비스 검사 // 기타 서비스 검사
for (final entry in LegacyServiceData.otherServices.entries) { for (final entry in LegacyServiceData.otherServices.entries) {
if (lowerName.contains(entry.key.toLowerCase())) { if (lowerName.contains(entry.key.toLowerCase())) {
print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
@@ -174,15 +176,15 @@ class UrlMatcherService {
for (final entry in LegacyServiceData.allServices.entries) { for (final entry in LegacyServiceData.allServices.entries) {
final key = entry.key.toLowerCase(); final key = entry.key.toLowerCase();
if (key.contains(lowerName) || lowerName.contains(key)) { if (key.contains(lowerName) || lowerName.contains(key)) {
print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}'); Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
return entry.value; return entry.value;
} }
} }
print('UrlMatcherService: 매칭 실패 - $lowerName'); Log.d('UrlMatcherService: 매칭 실패 - $lowerName');
return null; return null;
} catch (e) { } catch (e) {
print('UrlMatcherService: suggestUrl 에러 - $e'); Log.e('UrlMatcherService: suggestUrl 에러', e);
return null; return null;
} }
} }

View File

@@ -210,6 +210,7 @@ class TestSmsData {
} }
} }
// ignore: avoid_print
print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}'); print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}');
return resultData; return resultData;
} }
@@ -233,7 +234,7 @@ class TestSmsData {
]; ];
// Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제) // Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제)
final microsoftMonthlyCost = 12800.0 / 12; const microsoftMonthlyCost = 12800.0 / 12;
// 최근 6개월 데이터 생성 // 최근 6개월 데이터 생성
for (int i = 0; i < 6; i++) { for (int i = 0; i < 6; i++) {

View File

@@ -19,24 +19,21 @@ class AdaptiveTheme {
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.dangerColor,
background: const Color(0xFF121212), surface: Color(0xFF1E1E1E),
surface: const Color(0xFF1E1E1E),
), ),
scaffoldBackgroundColor: const Color(0xFF121212), scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: const Color(0xFF1E1E1E), color: const Color(0xFF1E1E1E),
elevation: 2, elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.3), shadowColor: Colors.black.withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), 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, clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -53,7 +50,6 @@ class AdaptiveTheme {
size: 24, size: 24,
), ),
), ),
textTheme: TextTheme( textTheme: TextTheme(
headlineLarge: const TextStyle( headlineLarge: const TextStyle(
color: Colors.white, color: Colors.white,
@@ -119,22 +115,24 @@ class AdaptiveTheme {
height: 1.5, height: 1.5,
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: const Color(0xFF2A2A2A), fillColor: const Color(0xFF2A2A2A),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), 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( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), borderSide:
const BorderSide(color: AppColors.primaryColor, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -151,7 +149,6 @@ class AdaptiveTheme {
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor, backgroundColor: AppColors.primaryColor,
@@ -164,7 +161,6 @@ class AdaptiveTheme {
elevation: 0, elevation: 0,
), ),
), ),
dividerTheme: DividerThemeData( dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1), color: Colors.white.withValues(alpha: 0.1),
thickness: 1, thickness: 1,
@@ -178,7 +174,6 @@ class AdaptiveTheme {
return darkTheme.copyWith( return darkTheme.copyWith(
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith( colorScheme: darkTheme.colorScheme.copyWith(
background: Colors.black,
surface: const Color(0xFF0A0A0A), surface: const Color(0xFF0A0A0A),
), ),
cardTheme: darkTheme.cardTheme.copyWith( cardTheme: darkTheme.cardTheme.copyWith(
@@ -203,10 +198,8 @@ class AdaptiveTheme {
secondary: Colors.black87, secondary: Colors.black87,
tertiary: Colors.black54, tertiary: Colors.black54,
error: Colors.red, error: Colors.red,
background: Colors.white,
surface: Colors.white, surface: Colors.white,
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
headlineLarge: TextStyle( headlineLarge: TextStyle(
color: Colors.black, color: Colors.black,
@@ -234,7 +227,6 @@ class AdaptiveTheme {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -242,7 +234,6 @@ class AdaptiveTheme {
side: const BorderSide(color: Colors.black, width: 2), side: const BorderSide(color: Colors.black, width: 2),
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -263,20 +254,17 @@ class AdaptiveTheme {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
statusBarIconBrightness: brightness == Brightness.dark statusBarIconBrightness:
? Brightness.light brightness == Brightness.dark ? Brightness.light : Brightness.dark,
: Brightness.dark, statusBarBrightness:
statusBarBrightness: brightness == Brightness.dark brightness == Brightness.dark ? Brightness.light : Brightness.dark,
? Brightness.light
: Brightness.dark,
systemNavigationBarColor: isOled systemNavigationBarColor: isOled
? Colors.black ? Colors.black
: (brightness == Brightness.dark : (brightness == Brightness.dark
? const Color(0xFF121212) ? const Color(0xFF121212)
: Colors.white), : Colors.white),
systemNavigationBarIconBrightness: brightness == Brightness.dark systemNavigationBarIconBrightness:
? Brightness.light brightness == Brightness.dark ? Brightness.light : Brightness.dark,
: Brightness.dark,
)); ));
} }

View File

@@ -10,7 +10,6 @@ class AppTheme {
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor, tertiary: AppColors.infoColor,
error: AppColors.dangerColor, error: AppColors.dangerColor,
background: AppColors.backgroundColor,
surface: AppColors.surfaceColor, surface: AppColors.surfaceColor,
), ),
@@ -36,13 +35,13 @@ class AppTheme {
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: const TextStyle( titleTextStyle: TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
), ),
iconTheme: const IconThemeData( iconTheme: IconThemeData(
color: AppColors.primaryColor, color: AppColors.primaryColor,
size: 24, size: 24,
), ),
@@ -51,21 +50,21 @@ class AppTheme {
// 타이포그래피 - Metronic Tailwind 스타일 // 타이포그래피 - Metronic Tailwind 스타일
textTheme: const TextTheme( textTheme: const TextTheme(
// 헤드라인 - 페이지 제목 // 헤드라인 - 페이지 제목
headlineLarge: const TextStyle( headlineLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineMedium: const TextStyle( headlineMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineSmall: const TextStyle( headlineSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -74,7 +73,7 @@ class AppTheme {
), ),
// 타이틀 - 카드, 섹션 제목 // 타이틀 - 카드, 섹션 제목
titleLarge: const TextStyle( titleLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -257,14 +256,14 @@ class AppTheme {
// 스위치 스타일 // 스위치 스타일
switchTheme: SwitchThemeData( switchTheme: SwitchThemeData(
thumbColor: MaterialStateProperty.resolveWith<Color>((states) { thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return Colors.white; return Colors.white;
}), }),
trackColor: MaterialStateProperty.resolveWith<Color>((states) { trackColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.secondaryColor.withValues(alpha: 0.5); return AppColors.secondaryColor.withValues(alpha: 0.5);
} }
return AppColors.borderColor; return AppColors.borderColor;
@@ -273,8 +272,8 @@ class AppTheme {
// 체크박스 스타일 // 체크박스 스타일
checkboxTheme: CheckboxThemeData( checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return Colors.transparent; return Colors.transparent;
@@ -287,8 +286,8 @@ class AppTheme {
// 라디오 버튼 스타일 // 라디오 버튼 스타일
radioTheme: RadioThemeData( radioTheme: RadioThemeData(
fillColor: MaterialStateProperty.resolveWith<Color>((states) { fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return AppColors.textSecondary; return AppColors.textSecondary;
@@ -311,12 +310,12 @@ class AppTheme {
labelColor: AppColors.primaryColor, labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary, unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor, indicatorColor: AppColors.primaryColor,
labelStyle: const TextStyle( labelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
unselectedLabelStyle: const TextStyle( unselectedLabelStyle: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.1, letterSpacing: 0.1,

27
lib/utils/logger.dart Normal file
View File

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

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'logger.dart';
import 'dart:async'; import 'dart:async';
/// 메모리 관리를 위한 헬퍼 클래스 /// 메모리 관리를 위한 헬퍼 클래스
@@ -57,15 +58,14 @@ class MemoryManager {
void clearCache() { void clearCache() {
_cache.clear(); _cache.clear();
if (kDebugMode) { if (kDebugMode) {
print('🧹 메모리 캐시가 비워졌습니다.'); Log.d('🧹 메모리 캐시가 비워졌습니다.');
} }
} }
/// 특정 패턴의 캐시 제거 /// 특정 패턴의 캐시 제거
void clearCacheByPattern(String pattern) { void clearCacheByPattern(String pattern) {
final keysToRemove = _cache.keys final keysToRemove =
.where((key) => key.contains(pattern)) _cache.keys.where((key) => key.contains(pattern)).toList();
.toList();
for (final key in keysToRemove) { for (final key in keysToRemove) {
_cache.remove(key); _cache.remove(key);
@@ -123,7 +123,7 @@ class MemoryManager {
PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages(); PaintingBinding.instance.imageCache.clearLiveImages();
if (kDebugMode) { if (kDebugMode) {
print('🖼️ 이미지 캐시가 비워졌습니다.'); Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
} }
} }
@@ -139,9 +139,7 @@ class MemoryManager {
/// 살아있는 위젯 수 확인 /// 살아있는 위젯 수 확인
int getAliveWidgetCount() { int getAliveWidgetCount() {
return _widgetReferences.values return _widgetReferences.values.where((ref) => ref.target != null).length;
.where((ref) => ref.target != null)
.length;
} }
/// 메모리 압박 시 대응 /// 메모리 압박 시 대응
@@ -158,7 +156,7 @@ class MemoryManager {
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
if (kDebugMode) { if (kDebugMode) {
print('⚠️ 메모리 압박 대응: 캐시 크기 감소'); Log.w('메모리 압박 대응: 캐시 크기 감소');
} }
} }
@@ -231,7 +229,8 @@ class ImageCacheStatus {
}); });
double get sizeUsagePercentage => (currentSize / maximumSize) * 100; double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100; double get bytesUsagePercentage =>
(currentSizeBytes / maximumSizeBytes) * 100;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'currentSize': currentSize, 'currentSize': currentSize,
@@ -263,10 +262,8 @@ class MemoryEfficientListView<T> extends StatefulWidget {
_MemoryEfficientListViewState<T>(); _MemoryEfficientListViewState<T>();
} }
class _MemoryEfficientListViewState<T> class _MemoryEfficientListViewState<T> extends State<MemoryEfficientListView<T>>
extends State<MemoryEfficientListView<T>>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
@override @override
bool get wantKeepAlive => false; bool get wantKeepAlive => false;

View File

@@ -1,11 +1,13 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'logger.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'dart:async'; import 'dart:async';
/// 성능 최적화를 위한 유틸리티 클래스 /// 성능 최적화를 위한 유틸리티 클래스
class PerformanceOptimizer { class PerformanceOptimizer {
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal(); static final PerformanceOptimizer _instance =
PerformanceOptimizer._internal();
factory PerformanceOptimizer() => _instance; factory PerformanceOptimizer() => _instance;
PerformanceOptimizer._internal(); PerformanceOptimizer._internal();
@@ -104,7 +106,8 @@ class PerformanceOptimizer {
} }
/// 이미지 최적화 - 메모리 효율적인 크기로 조정 /// 이미지 최적화 - 메모리 효율적인 크기로 조정
static double getOptimalImageSize(BuildContext context, { static double getOptimalImageSize(
BuildContext context, {
required double originalSize, required double originalSize,
double maxSize = 1000, double maxSize = 1000,
}) { }) {
@@ -139,12 +142,12 @@ class PerformanceOptimizer {
/// 빌드 최적화를 위한 const 위젯 권장사항 체크 /// 빌드 최적화를 위한 const 위젯 권장사항 체크
static void checkConstOptimization() { static void checkConstOptimization() {
if (kDebugMode) { if (kDebugMode) {
print('💡 성능 최적화 팁:'); Log.i('💡 성능 최적화 팁:\n'
print('1. 가능한 모든 위젯에 const 사용'); '1. 가능한 모든 위젯에 const 사용\n'
print('2. StatelessWidget 대신 const 생성자 사용'); '2. StatelessWidget 대신 const 생성자 사용\n'
print('3. 큰 리스트는 ListView.builder 사용'); '3. 큰 리스트는 ListView.builder 사용\n'
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드'); '4. 이미지는 캐싱과 함께 적절한 크기로 로드\n'
print('5. 애니메이션은 AnimatedBuilder 사용'); '5. 애니메이션은 AnimatedBuilder 사용');
} }
} }
@@ -154,12 +157,12 @@ class PerformanceOptimizer {
static void trackWidget(String widgetName, bool isCreated) { static void trackWidget(String widgetName, bool isCreated) {
if (!kDebugMode) return; if (!kDebugMode) return;
_widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) + _widgetCounts[widgetName] =
(isCreated ? 1 : -1); (_widgetCounts[widgetName] ?? 0) + (isCreated ? 1 : -1);
// 위젯이 비정상적으로 많이 생성되면 경고 // 위젯이 비정상적으로 많이 생성되면 경고
if ((_widgetCounts[widgetName] ?? 0) > 100) { if ((_widgetCounts[widgetName] ?? 0) > 100) {
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!'); Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
} }
} }
} }
@@ -176,8 +179,10 @@ class MemoryInfo {
double get usagePercentage => (currentUsage / capacity) * 100; double get usagePercentage => (currentUsage / capacity) * 100;
String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB'; String get formattedUsage =>
String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB'; '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedCapacity =>
'${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
} }
/// 성능 측정 데코레이터 /// 성능 측정 데코레이터
@@ -192,11 +197,11 @@ class PerformanceMeasure {
try { try {
final result = await operation(); final result = await operation();
stopwatch.stop(); stopwatch.stop();
print('$name 완료: ${stopwatch.elapsedMilliseconds}ms'); Log.d('$name 완료: ${stopwatch.elapsedMilliseconds}ms');
return result; return result;
} catch (e) { } catch (e) {
stopwatch.stop(); stopwatch.stop();
print('$name 실패: ${stopwatch.elapsedMilliseconds}ms - $e'); Log.e('$name 실패: ${stopwatch.elapsedMilliseconds}ms', e);
rethrow; rethrow;
} }
} }

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import '../services/url_matcher/data/legacy_service_data.dart'; import '../services/url_matcher/data/legacy_service_data.dart';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스 /// 구독 서비스를 카테고리별로 구분하는 도우미 클래스
@@ -86,8 +85,8 @@ class SubscriptionCategoryHelper {
categorizedSubscriptions['shoppingEcommerce']!.add(subscription); categorizedSubscriptions['shoppingEcommerce']!.add(subscription);
} }
// 프로그래밍 // 프로그래밍
else if (_isInCategory(subscription.serviceName, else if (_isInCategory(
LegacyServiceData.programmingServices)) { subscription.serviceName, LegacyServiceData.programmingServices)) {
if (!categorizedSubscriptions.containsKey('programming')) { if (!categorizedSubscriptions.containsKey('programming')) {
categorizedSubscriptions['programming'] = []; categorizedSubscriptions['programming'] = [];
} }

View File

@@ -6,7 +6,8 @@ import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 App Bar /// 구독 추가 화면의 App Bar
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { class AddSubscriptionAppBar extends StatelessWidget
implements PreferredSizeWidget {
final AddSubscriptionController controller; final AddSubscriptionController controller;
final double scrollOffset; final double scrollOffset;
final VoidCallback onScanSMS; final VoidCallback onScanSMS;

View File

@@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 이벤트/할인 섹션 /// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget { class AddSubscriptionEventSection extends StatelessWidget {
@@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -66,7 +65,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: controller.gradientColors[0].withValues(alpha: 0.1), color: controller.gradientColors[0]
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon( child: Icon(
@@ -146,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -155,7 +155,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Expanded( Expanded(
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final locale = Localizations.localeOf(context); final locale =
Localizations.localeOf(context);
String infoText; String infoText;
switch (locale.languageCode) { switch (locale.languageCode) {
case 'ko': case 'ko':
@@ -168,11 +169,12 @@ class AddSubscriptionEventSection extends StatelessWidget {
infoText = '设置折扣或促销价格'; infoText = '设置折扣或促销价格';
break; break;
default: default:
infoText = 'Set up discount or promotion price'; infoText =
'Set up discount or promotion price';
} }
return Text( return Text(
infoText, infoText,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -216,8 +218,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
setState(() { setState(() {
controller.eventStartDate = date; controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) { if (date != null &&
controller.eventEndDate = date.add(const Duration(days: 30)); controller.eventEndDate == null) {
controller.eventEndDate =
date.add(const Duration(days: 30));
} }
}); });
}, },
@@ -238,7 +242,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Builder( Builder(
builder: (BuildContext innerContext) { builder: (BuildContext innerContext) {
// 현재 로케일 확인 // 현재 로케일 확인
final currentLocale = Localizations.localeOf(innerContext); final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정 // 로케일에 따라 직접 텍스트 설정
String eventPriceLabel; String eventPriceLabel;

View File

@@ -5,7 +5,6 @@ import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart'; import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget { class AnalysisBadge extends StatelessWidget {
@@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
color: borderColor, color: borderColor,
width: 2, width: 2,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
@@ -69,13 +68,17 @@ class AnalysisBadge extends StatelessWidget {
String displayText = amountText; String displayText = amountText;
if (amountText.length > 12) { if (amountText.length > 12) {
// 괄호 안의 내용 제거 // 괄호 안의 내용 제거
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim(); displayText =
amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
} }
if (displayText.length > 10) { if (displayText.length > 10) {
// 통화 기호만 남기고 숫자만 표시 // 통화 기호만 남기고 숫자만 표시
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency); final currencySymbol =
displayText = displayText.replaceAll(currencySymbol, '').trim(); CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText = '$currencySymbol${displayText.substring(0, 6)}...'; displayText =
displayText.replaceAll(currencySymbol, '').trim();
displayText =
'$currencySymbol${displayText.substring(0, 6)}...';
} }
return Text( return Text(
displayText, displayText,

View File

@@ -48,10 +48,12 @@ class EventAnalysisCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: AppLocalizations.of(context).eventDiscountStatus, text: AppLocalizations.of(context)
.eventDiscountStatus,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
@@ -79,7 +81,10 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length), AppLocalizations.of(context)
.servicesInProgress(provider
.activeEventSubscriptions
.length),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -97,15 +102,18 @@ class EventAnalysisCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
const Color(0xFFFF6B6B).withValues(alpha: 0.1), const Color(0xFFFF6B6B)
const Color(0xFFFF8787).withValues(alpha: 0.1), .withValues(alpha: 0.1),
const Color(0xFFFF8787)
.withValues(alpha: 0.1),
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3), color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.3),
), ),
), ),
child: Row( child: Row(
@@ -118,10 +126,12 @@ class EventAnalysisCard extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
ThemedText( ThemedText(
AppLocalizations.of(context).monthlySavingAmount, AppLocalizations.of(context)
.monthlySavingAmount,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -154,24 +164,29 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) { ...provider.activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice); final savings = sub.originalPrice -
(sub.eventPrice ?? sub.originalPrice);
final discountRate = final discountRate =
((savings / sub.originalPrice) * 100).round(); ((savings / sub.originalPrice) * 100)
.round();
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.darkNavy.withValues(alpha: 0.05), color: AppColors.darkNavy
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.darkNavy.withValues(alpha: 0.1), color: AppColors.darkNavy
.withValues(alpha: 0.1),
), ),
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
ThemedText( ThemedText(
sub.serviceName, sub.serviceName,
@@ -184,8 +199,8 @@ class EventAnalysisCard extends StatelessWidget {
Row( Row(
children: [ children: [
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil future:
.formatAmount( CurrencyUtil.formatAmount(
sub.originalPrice, sub.originalPrice,
sub.currency), sub.currency),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -194,9 +209,11 @@ class EventAnalysisCard extends StatelessWidget {
snapshot.data!, snapshot.data!,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
decoration: TextDecoration decoration:
TextDecoration
.lineThrough, .lineThrough,
color: AppColors.navyGray, color: AppColors
.navyGray,
), ),
); );
} }
@@ -211,9 +228,10 @@ class EventAnalysisCard extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil future:
.formatAmount( CurrencyUtil.formatAmount(
sub.eventPrice ?? sub.originalPrice, sub.eventPrice ??
sub.originalPrice,
sub.currency), sub.currency),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
@@ -244,7 +262,8 @@ class EventAnalysisCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF6B6B) color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius:
BorderRadius.circular(4),
), ),
child: Text( child: Text(
'$discountRate${AppLocalizations.of(context).discountPercent}', '$discountRate${AppLocalizations.of(context).discountPercent}',

View File

@@ -35,7 +35,8 @@ class MonthlyExpenseChartCard extends StatelessWidget {
if (maxValue <= 1000000) return 1000000; 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(); return ((maxValue / magnitude).ceil() * magnitude).toDouble();
} else { } else {
// 소수점이 있는 통화 (달러, 위안) // 소수점이 있는 통화 (달러, 위안)
@@ -153,8 +154,9 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 바 차트 // 바 차트 (RepaintBoundary로 페인트 분리)
AspectRatio( RepaintBoundary(
child: AspectRatio(
aspectRatio: 1.6, aspectRatio: 1.6,
child: BarChart( child: BarChart(
BarChartData( BarChartData(
@@ -164,8 +166,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
0, 0,
(max, data) => math.max( (max, data) => math.max(
max, data['totalExpense'] as double)), max, data['totalExpense'] as double)),
locale locale),
),
barGroups: _getMonthlyBarGroups(locale), barGroups: _getMonthlyBarGroups(locale),
gridData: FlGridData( gridData: FlGridData(
show: true, show: true,
@@ -176,13 +177,12 @@ class MonthlyExpenseChartCard extends StatelessWidget {
0, 0,
(max, data) => math.max(max, (max, data) => math.max(max,
data['totalExpense'] as double)), data['totalExpense'] as double)),
locale locale),
), CurrencyUtil.getDefaultCurrency(locale)),
CurrencyUtil.getDefaultCurrency(locale)
),
getDrawingHorizontalLine: (value) { getDrawingHorizontalLine: (value) {
return FlLine( return FlLine(
color: AppColors.navyGray.withValues(alpha: 0.1), color:
AppColors.navyGray.withValues(alpha: 0.1),
strokeWidth: 1, strokeWidth: 1,
); );
}, },
@@ -233,9 +233,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
children: [ children: [
TextSpan( TextSpan(
text: CurrencyUtil.formatTotalAmountWithLocale( text: CurrencyUtil
monthlyData[group.x]['totalExpense'] .formatTotalAmountWithLocale(
as double, monthlyData[group.x]
['totalExpense'] as double,
locale), locale),
style: const TextStyle( style: const TextStyle(
color: Color(0xFFFBBF24), color: Color(0xFFFBBF24),
@@ -251,10 +252,12 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
), ),
), ),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
child: ThemedText.caption( child: ThemedText.caption(
text: AppLocalizations.of(context).monthlySubscriptionExpense, text: AppLocalizations.of(context)
.monthlySubscriptionExpense,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -23,7 +23,8 @@ class SubscriptionPieChartCard extends StatefulWidget {
}); });
@override @override
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState(); State<SubscriptionPieChartCard> createState() =>
_SubscriptionPieChartCardState();
} }
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> { class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
@@ -78,7 +79,6 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산) // 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
Future<List<PieChartSectionData>> _getPieSections() async { Future<List<PieChartSectionData>> _getPieSections() async {
if (widget.subscriptions.isEmpty) return []; if (widget.subscriptions.isEmpty) return [];
// 현재 locale 가져오기 // 현재 locale 가져오기
@@ -97,11 +97,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
sectionValues.add(value); sectionValues.add(value);
} else if (subscription.currency == 'USD') { } else if (subscription.currency == 'USD') {
// USD를 기본 통화로 변환 // USD를 기본 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency); final converted = await ExchangeRateService()
.convertUsdToTarget(value, defaultCurrency);
sectionValues.add(converted ?? value); sectionValues.add(converted ?? value);
} else if (defaultCurrency == 'USD') { } else if (defaultCurrency == 'USD') {
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환 // 기본 통화가 USD인 경우 다른 통화를 USD로 변환
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency); final converted = await ExchangeRateService()
.convertTargetToUsd(value, subscription.currency);
sectionValues.add(converted ?? value); sectionValues.add(converted ?? value);
} else { } else {
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우) // 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
@@ -159,7 +161,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
} }
// 터치 상태를 반영한 섹션 데이터 생성 // 터치 상태를 반영한 섹션 데이터 생성
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) { List<PieChartSectionData> _applyTouchedState(
List<PieChartSectionData> sections) {
return List.generate(sections.length, (i) { return List.generate(sections.length, (i) {
final section = sections[i]; final section = sections[i];
final isTouched = _touchedIndex == i; final isTouched = _touchedIndex == i;
@@ -169,12 +172,14 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
return PieChartSectionData( return PieChartSectionData(
value: section.value, value: section.value,
title: section.title, title: section.title,
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle( titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ??
TextStyle(
fontSize: fontSize, fontSize: fontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: AppColors.pureWhite,
shadows: const [ shadows: const [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) Shadow(
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
], ],
), ),
color: section.color, color: section.color,
@@ -217,18 +222,20 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: AppLocalizations.of(context).subscriptionServiceRatio, text: AppLocalizations.of(context)
.subscriptionServiceRatio,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale( future: CurrencyUtil.getExchangeRateInfoForLocale(
context.watch<LocaleProvider>().locale.languageCode context
), .watch<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && if (snapshot.hasData && snapshot.data!.isNotEmpty) {
snapshot.data!.isNotEmpty) {
return Container( return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@@ -236,15 +243,15 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE5F2FF), color: const Color(0xFFE5F2FF),
borderRadius: borderRadius: BorderRadius.circular(4),
BorderRadius.circular(4),
border: Border.all( border: Border.all(
color: const Color(0xFFBFDBFE), color: const Color(0xFFBFDBFE),
width: 1, width: 1,
), ),
), ),
child: Text( child: Text(
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!), AppLocalizations.of(context)
.exchangeRateFormat(snapshot.data!),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -272,7 +279,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
height: 250, height: 250,
child: Center( child: Center(
child: ThemedText( child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices, AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
), ),
@@ -284,16 +292,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
child: FutureBuilder<List<PieChartSectionData>>( child: FutureBuilder<List<PieChartSectionData>>(
future: _pieSectionsFuture, future: _pieSectionsFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center( return const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
} }
if (!snapshot.hasData || snapshot.data!.isEmpty) { if (!snapshot.hasData ||
snapshot.data!.isEmpty) {
return Center( return Center(
child: ThemedText( child: ThemedText(
AppLocalizations.of(context).noSubscriptionServices, AppLocalizations.of(context)
.noSubscriptionServices,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
), ),
@@ -306,14 +317,16 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
sectionsSpace: 2, sectionsSpace: 2,
centerSpaceRadius: 60, centerSpaceRadius: 60,
sections: _applyTouchedState(snapshot.data!), sections:
_applyTouchedState(snapshot.data!),
pieTouchData: PieTouchData( pieTouchData: PieTouchData(
enabled: true, enabled: true,
touchCallback: (FlTouchEvent event, touchCallback: (FlTouchEvent event,
pieTouchResponse) { pieTouchResponse) {
// 터치 응답이 없거나 섹션이 없는 경우 // 터치 응답이 없거나 섹션이 없는 경우
if (pieTouchResponse == null || if (pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) { pieTouchResponse.touchedSection ==
null) {
// 차트 밖으로 나갔을 때만 리셋 // 차트 밖으로 나갔을 때만 리셋
if (_touchedIndex != -1) { if (_touchedIndex != -1) {
setState(() { setState(() {
@@ -331,7 +344,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
if (event is FlTapUpEvent) { if (event is FlTapUpEvent) {
setState(() { setState(() {
// 동일 섹션 탭하면 선택 해제, 아니면 선택 // 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex; _touchedIndex = (_touchedIndex ==
touchedIndex)
? -1
: touchedIndex;
}); });
return; return;
} }
@@ -364,10 +380,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
(index) { (index) {
final subscription = final subscription =
widget.subscriptions[index]; widget.subscriptions[index];
final color = _chartColors[index % _chartColors.length]; final color =
_chartColors[index % _chartColors.length];
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(bottom: 4.0),
bottom: 4.0),
child: Row( child: Row(
children: [ children: [
Container( Container(
@@ -385,31 +401,31 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
), ),
overflow: overflow: TextOverflow.ellipsis,
TextOverflow.ellipsis,
), ),
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil future: CurrencyUtil
.formatSubscriptionAmountWithLocale( .formatSubscriptionAmountWithLocale(
subscription, subscription,
context.read<LocaleProvider>().locale.languageCode), context
.read<LocaleProvider>()
.locale
.languageCode),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return ThemedText( return ThemedText(
snapshot.data!, snapshot.data!,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: fontWeight: FontWeight.bold,
FontWeight.bold,
), ),
); );
} }
return const SizedBox( return const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: child: CircularProgressIndicator(
CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
), ),
); );

View File

@@ -56,7 +56,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ThemedText.headline( ThemedText.headline(
text: AppLocalizations.of(context).totalExpenseSummary, text:
AppLocalizations.of(context).totalExpenseSummary,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
), ),
@@ -67,20 +68,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
onPressed: () async { onPressed: () async {
final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale); final totalExpenseText =
CurrencyUtil.formatTotalAmountWithLocale(
totalExpense, locale);
await Clipboard.setData( await Clipboard.setData(
ClipboardData(text: totalExpenseText)); ClipboardData(text: totalExpenseText));
HapticFeedbackHelper.lightImpact(); HapticFeedbackHelper.lightImpact();
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)), content: Text(AppLocalizations.of(context)
.totalExpenseCopied(totalExpenseText)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3), backgroundColor: AppColors.glassBackground
.withValues(alpha: 0.3),
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
@@ -115,7 +120,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale), CurrencyUtil.formatTotalAmountWithLocale(
totalExpense, locale),
style: const TextStyle( style: const TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -134,10 +140,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassBackground.withValues(alpha: 0.3), color: AppColors.glassBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.2), color: AppColors.glassBorder
.withValues(alpha: 0.2),
), ),
), ),
child: const FaIcon( child: const FaIcon(
@@ -152,7 +160,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
ThemedText.caption( ThemedText.caption(
text: AppLocalizations.of(context).totalServices, text: AppLocalizations.of(context)
.totalServices,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -160,7 +169,9 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
ThemedText( ThemedText(
AppLocalizations.of(context).subscriptionCount(subscriptions.length), AppLocalizations.of(context)
.subscriptionCount(
subscriptions.length),
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -176,10 +187,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.glassBackground.withValues(alpha: 0.3), color: AppColors.glassBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.2), color: AppColors.glassBorder
.withValues(alpha: 0.2),
), ),
), ),
child: const FaIcon( child: const FaIcon(
@@ -194,7 +207,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
ThemedText.caption( ThemedText.caption(
text: AppLocalizations.of(context).averageCost, text: AppLocalizations.of(context)
.averageCost,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -202,10 +216,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
ThemedText( ThemedText(
CurrencyUtil.formatTotalAmountWithLocale( CurrencyUtil
.formatTotalAmountWithLocale(
subscriptions.isEmpty subscriptions.isEmpty
? 0 ? 0
: totalExpense / subscriptions.length, : totalExpense /
subscriptions.length,
locale), locale),
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,

View File

@@ -138,7 +138,8 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
transitionDuration: const Duration(milliseconds: 800), transitionDuration: const Duration(milliseconds: 800),
reverseTransitionDuration: const Duration(milliseconds: 800), reverseTransitionDuration: const Duration(milliseconds: 800),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
final isAnimatingForward = animation.status == AnimationStatus.forward; final isAnimatingForward =
animation.status == AnimationStatus.forward;
final flipAnimation = Tween( final flipAnimation = Tween(
begin: 0.0, begin: 0.0,

View File

@@ -109,8 +109,8 @@ class AnimatedWaveBackground extends StatelessWidget {
width: 30, width: 30,
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: color: Colors.white.withValues(
0.1 + 0.1 * pulseController.value, alpha: 0.1 + 0.1 * pulseController.value,
), ),
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),

View File

@@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../utils/logger.dart';
import 'animated_page_transitions.dart'; import 'animated_page_transitions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -42,8 +43,9 @@ class AppNavigator {
} }
/// 구독 상세 화면으로 네비게이션 /// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async { static Future<void> toDetail(
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); BuildContext context, SubscriptionModel subscription) async {
Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
try { try {
@@ -51,9 +53,9 @@ class AppNavigator {
AppRoutes.subscriptionDetail, AppRoutes.subscriptionDetail,
arguments: subscription, arguments: subscription,
); );
print('DetailScreen 네비게이션 성공'); Log.d('DetailScreen 네비게이션 성공');
} catch (e) { } catch (e) {
print('DetailScreen 네비게이션 오류: $e'); Log.e('DetailScreen 네비게이션 오류', e);
} }
} }
@@ -196,6 +198,7 @@ class AppNavigationObserver extends NavigatorObserver {
@override @override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) { void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: 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}');
} }
} }

View File

@@ -68,7 +68,8 @@ class CategoryHeaderWidget extends StatelessWidget {
final parts = <String>[]; final parts = <String>[];
// 개수는 항상 표시 // 개수는 항상 표시
parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount)); parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리 // 통화 부분을 별도로 처리
final currencyParts = <String>[]; final currencyParts = <String>[];

View File

@@ -74,8 +74,7 @@ class _DangerButtonState extends State<DangerButton> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
widget.confirmationMessage ?? widget.confirmationMessage ?? '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
'이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,

View File

@@ -43,8 +43,10 @@ class _PrimaryButtonState extends State<PrimaryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor; final effectiveBackgroundColor =
final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite; widget.backgroundColor ?? theme.primaryColor;
final effectiveForegroundColor =
widget.foregroundColor ?? AppColors.pureWhite;
Widget button = AnimatedContainer( Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -64,7 +66,8 @@ class _PrimaryButtonState extends State<PrimaryButton> {
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0, elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08), shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6), disabledBackgroundColor:
effectiveBackgroundColor.withValues(alpha: 0.6),
), ),
child: widget.isLoading child: widget.isLoading
? SizedBox( ? SizedBox(

View File

@@ -42,7 +42,6 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor; final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor; final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
@@ -66,13 +65,13 @@ class _SecondaryButtonState extends State<SecondaryButton> {
: effectiveBorderColor, : effectiveBorderColor,
width: widget.borderWidth, width: widget.borderWidth,
), ),
padding: widget.padding ?? const EdgeInsets.symmetric( padding: widget.padding ??
const EdgeInsets.symmetric(
vertical: 12, vertical: 12,
horizontal: 24, horizontal: 24,
), ),
backgroundColor: _isHovered backgroundColor:
? AppColors.glassBackground _isHovered ? AppColors.glassBackground : Colors.transparent,
: Colors.transparent,
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -179,9 +178,8 @@ class _TextLinkButtonState extends State<TextLinkButton> {
fontSize: widget.fontSize, fontSize: widget.fontSize,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: effectiveColor, color: effectiveColor,
decoration: _isHovered decoration:
? TextDecoration.underline _isHovered ? TextDecoration.underline : TextDecoration.none,
: TextDecoration.none,
), ),
), ),
], ],

View File

@@ -34,7 +34,8 @@ class SectionCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final effectiveBackgroundColor = backgroundColor ?? Colors.white; final effectiveBackgroundColor = backgroundColor ?? Colors.white;
final effectiveShadow = boxShadow ?? [ final effectiveShadow = boxShadow ??
[
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.05), color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10, blurRadius: 10,

View File

@@ -53,7 +53,8 @@ class ConfirmationDialog extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1), color:
(iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon( child: Icon(

View File

@@ -81,8 +81,8 @@ class LoadingDialog {
context: context, context: context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
barrierColor: barrierColor ?? Colors.black54, barrierColor: barrierColor ?? Colors.black54,
builder: (context) => WillPopScope( builder: (context) => PopScope(
onWillPop: () async => barrierDismissible, canPop: barrierDismissible,
child: Center( child: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -193,7 +193,8 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
width: widget.size / 5, width: widget.size / 5,
height: widget.size / 5, height: widget.size / 5,
decoration: BoxDecoration( decoration: BoxDecoration(
color: effectiveColor.withValues(alpha: 0.3 + value * 0.7), color:
effectiveColor.withValues(alpha: 0.3 + value * 0.7),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
); );
@@ -220,7 +221,8 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
height: widget.size * (0.3 + _animation.value * 0.5), height: widget.size * (0.3 + _animation.value * 0.5),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: effectiveColor.withValues(alpha: 1 - _animation.value), color:
effectiveColor.withValues(alpha: 1 - _animation.value),
), ),
), ),
), ),

View File

@@ -66,7 +66,7 @@ class BaseTextField extends StatelessWidget {
if (label != null) ...[ if (label != null) ...[
Text( Text(
label!, label!,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -90,13 +90,14 @@ class BaseTextField extends StatelessWidget {
minLines: minLines, minLines: minLines,
readOnly: readOnly, readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor, cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? TextStyle( style: style ??
const TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: TextStyle( hintStyle: const TextStyle(
color: AppColors.textMuted, color: AppColors.textMuted,
), ),
prefixIcon: prefixIcon, prefixIcon: prefixIcon,

View File

@@ -106,8 +106,6 @@ class BillingCycleSelector extends StatelessWidget {
if (isSelected) { if (isSelected) {
return Colors.white; return Colors.white;
} }
return isGlassmorphism return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
? AppColors.darkNavy
: Colors.grey[700]!;
} }
} }

View File

@@ -55,7 +55,8 @@ class CategorySelector extends StatelessWidget {
Consumer<CategoryProvider>( Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) { builder: (context, categoryProvider, child) {
return Text( return Text(
categoryProvider.getLocalizedCategoryName(context, category.name), categoryProvider.getLocalizedCategoryName(
context, category.name),
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -131,8 +132,6 @@ class CategorySelector extends StatelessWidget {
if (isSelected) { if (isSelected) {
return Colors.white; return Colors.white;
} }
return isGlassmorphism return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
? AppColors.darkNavy
: Colors.grey[700]!;
} }
} }

View File

@@ -105,7 +105,11 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
} }
double? _parseValue(String text) { double? _parseValue(String text) {
final cleanText = text.replaceAll(',', '').replaceAll('', '').replaceAll('\$', '').trim(); final cleanText = text
.replaceAll(',', '')
.replaceAll('', '')
.replaceAll('\$', '')
.trim();
return double.tryParse(cleanText); return double.tryParse(cleanText);
} }
@@ -128,10 +132,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow( FilteringTextInputFormatter.allow(
widget.currency == 'KRW' widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')),
? RegExp(r'[0-9]')
: RegExp(r'[0-9.]')
),
if (widget.currency == 'USD') if (widget.currency == 'USD')
// USD의 경우 소수점 이하 2자리까지만 허용 // USD의 경우 소수점 이하 2자리까지만 허용
TextInputFormatter.withFunction((oldValue, newValue) { TextInputFormatter.withFunction((oldValue, newValue) {
@@ -157,7 +158,8 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
final parsedValue = _parseValue(value); final parsedValue = _parseValue(value);
widget.onChanged?.call(parsedValue); widget.onChanged?.call(parsedValue);
}, },
validator: widget.validator ?? (value) { validator: widget.validator ??
(value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return AppLocalizations.of(context).amountRequired; return AppLocalizations.of(context).amountRequired;
} }

View File

@@ -131,9 +131,7 @@ class _CurrencyOption extends StatelessWidget {
Color _getBackgroundColor(ThemeData theme) { Color _getBackgroundColor(ThemeData theme) {
if (isSelected) { if (isSelected) {
return isGlassmorphism return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6);
? theme.primaryColor
: const Color(0xFF3B82F6);
} }
return isGlassmorphism return isGlassmorphism
? AppColors.surfaceColorAlt ? AppColors.surfaceColorAlt
@@ -154,8 +152,6 @@ class _CurrencyOption extends StatelessWidget {
if (isSelected) { if (isSelected) {
return Colors.white; return Colors.white;
} }
return isGlassmorphism return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!;
? AppColors.navyGray
: Colors.grey[600]!;
} }
} }

View File

@@ -48,7 +48,7 @@ class DatePickerField extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -57,12 +57,15 @@ class DatePickerField extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
InkWell( InkWell(
focusNode: focusNode, focusNode: focusNode,
onTap: enabled ? () async { onTap: enabled
? () async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
initialDate: selectedDate, initialDate: selectedDate,
firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)), firstDate: firstDate ??
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)), DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: lastDate ??
DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) { builder: (BuildContext context, Widget? child) {
return Theme( return Theme(
data: ThemeData.light().copyWith( data: ThemeData.light().copyWith(
@@ -81,7 +84,8 @@ class DatePickerField extends StatelessWidget {
if (picked != null && picked != selectedDate) { if (picked != null && picked != selectedDate) {
onDateSelected(picked); onDateSelected(picked);
} }
} : null, }
: null,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
padding: contentPadding ?? const EdgeInsets.all(16), padding: contentPadding ?? const EdgeInsets.all(16),
@@ -97,21 +101,19 @@ class DatePickerField extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate), DateFormat(effectiveDateFormat, locale.toString())
.format(selectedDate),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: enabled color:
? AppColors.textPrimary enabled ? AppColors.textPrimary : AppColors.textMuted,
: AppColors.textMuted,
), ),
), ),
), ),
Icon( Icon(
Icons.calendar_today, Icons.calendar_today,
size: 20, size: 20,
color: enabled color: enabled ? AppColors.navyGray : AppColors.textMuted,
? AppColors.navyGray
: AppColors.textMuted,
), ),
], ],
), ),
@@ -158,7 +160,8 @@ class DateRangePickerField extends StatelessWidget {
primaryColor: primaryColor, primaryColor: primaryColor,
onDateSelected: onStartDateSelected, onDateSelected: onStartDateSelected,
firstDate: DateTime.now().subtract(const Duration(days: 365)), 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), const SizedBox(width: 12),
@@ -203,7 +206,8 @@ class _DateRangeItem extends StatelessWidget {
final effectivePrimaryColor = primaryColor ?? theme.primaryColor; final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
return InkWell( return InkWell(
onTap: enabled ? () async { onTap: enabled
? () async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
initialDate: date ?? DateTime.now(), initialDate: date ?? DateTime.now(),
@@ -227,7 +231,8 @@ class _DateRangeItem extends StatelessWidget {
if (picked != null) { if (picked != null) {
onDateSelected(picked); onDateSelected(picked);
} }
} : null, }
: null,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -244,7 +249,7 @@ class _DateRangeItem extends StatelessWidget {
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@@ -252,14 +257,14 @@ class _DateRangeItem extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
date != null date != null
? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!) ? DateFormat(AppLocalizations.of(context).dateFormatShort)
.format(date!)
: AppLocalizations.of(context).dateSelect, : AppLocalizations.of(context).dateSelect,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: date != null color:
? AppColors.textPrimary date != null ? AppColors.textPrimary : AppColors.textMuted,
: AppColors.textMuted,
), ),
), ),
], ],

View File

@@ -200,7 +200,7 @@ class AppSnackBar {
width: 24, width: 24,
height: 24, height: 24,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
child: CircularProgressIndicator( child: const CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: AppColors.pureWhite, color: AppColors.pureWhite,
), ),

View File

@@ -44,11 +44,11 @@ class DetailEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -118,7 +118,7 @@ class DetailEventSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -127,7 +127,7 @@ class DetailEventSection extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).eventPriceHint, AppLocalizations.of(context).eventPriceHint,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -146,7 +146,8 @@ class DetailEventSection extends StatelessWidget {
controller.eventStartDate = date; controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) { if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30)); controller.eventEndDate =
date.add(const Duration(days: 30));
} }
}, },
onEndDateSelected: (date) { onEndDateSelected: (date) {
@@ -166,11 +167,14 @@ class DetailEventSection extends StatelessWidget {
validator: controller.isEventActive validator: controller.isEventActive
? (value) { ? (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return AppLocalizations.of(context).eventPriceRequired; return AppLocalizations.of(context)
.eventPriceRequired;
} }
final price = double.tryParse(value.replaceAll(',', '')); final price =
double.tryParse(value.replaceAll(',', ''));
if (price == null || price <= 0) { if (price == null || price <= 0) {
return AppLocalizations.of(context).invalidPrice; return AppLocalizations.of(context)
.invalidPrice;
} }
return null; return null;
} }
@@ -181,9 +185,10 @@ class DetailEventSection extends StatelessWidget {
if (controller.eventPriceController.text.isNotEmpty) if (controller.eventPriceController.text.isNotEmpty)
_DiscountBadge( _DiscountBadge(
originalPrice: controller.subscription.monthlyCost, originalPrice: controller.subscription.monthlyCost,
eventPrice: double.tryParse( eventPrice: double.tryParse(controller
controller.eventPriceController.text.replaceAll(',', '') .eventPriceController.text
) ?? 0, .replaceAll(',', '')) ??
0,
currency: controller.currency, currency: controller.currency,
), ),
], ],
@@ -216,7 +221,8 @@ class _DiscountBadge extends StatelessWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round(); final discountPercentage =
((originalPrice - eventPrice) / originalPrice * 100).round();
final discountAmount = originalPrice - eventPrice; final discountAmount = originalPrice - eventPrice;
return Container( return Container(
@@ -234,7 +240,9 @@ class _DiscountBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()), AppLocalizations.of(context)
.discountPercent
.replaceAll('@', discountPercentage.toString()),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 12, fontSize: 12,
@@ -245,8 +253,8 @@ class _DiscountBadge extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
_getLocalizedDiscountAmount(context, currency, discountAmount), _getLocalizedDiscountAmount(context, currency, discountAmount),
style: TextStyle( style: const TextStyle(
color: const Color(0xFF15803D), color: Color(0xFF15803D),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, 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); final loc = AppLocalizations.of(context);
switch (currency) { switch (currency) {
case 'KRW': case 'KRW':
@@ -264,9 +273,11 @@ class _DiscountBadge extends StatelessWidget {
case 'JPY': case 'JPY':
return loc.discountAmountYen.replaceAll('@', amount.toInt().toString()); return loc.discountAmountYen.replaceAll('@', amount.toInt().toString());
case 'CNY': case 'CNY':
return loc.discountAmountYuan.replaceAll('@', amount.toStringAsFixed(2)); return loc.discountAmountYuan
.replaceAll('@', amount.toStringAsFixed(2));
default: // USD default: // USD
return loc.discountAmountDollar.replaceAll('@', amount.toStringAsFixed(2)); return loc.discountAmountDollar
.replaceAll('@', amount.toStringAsFixed(2));
} }
} }
} }

View File

@@ -49,11 +49,11 @@ class DetailFormSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -114,9 +114,9 @@ class DetailFormSection extends StatelessWidget {
controller.currency = value; controller.currency = value;
// 통화 변경시 금액 포맷 업데이트 // 통화 변경시 금액 포맷 업데이트
if (value == 'KRW') { if (value == 'KRW') {
final amount = double.tryParse( final amount = double.tryParse(controller
controller.monthlyCostController.text.replaceAll(',', '') .monthlyCostController.text
); .replaceAll(',', ''));
if (amount != null) { if (amount != null) {
controller.monthlyCostController.text = controller.monthlyCostController.text =
amount.toInt().toString(); amount.toInt().toString();
@@ -164,7 +164,8 @@ class DetailFormSection extends StatelessWidget {
}, },
label: AppLocalizations.of(context).nextBillingDate, label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(), firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)), lastDate:
DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor, primaryColor: baseColor,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -207,4 +208,3 @@ class DetailFormSection extends StatelessWidget {
); );
} }
} }

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
@@ -115,7 +114,8 @@ class DetailHeaderSection extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.1), color: Colors.black
.withValues(alpha: 0.1),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@@ -124,8 +124,10 @@ class DetailHeaderSection extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: WebsiteIcon( child: WebsiteIcon(
url: controller.websiteUrlController.text, url: controller
serviceName: controller.serviceNameController.text, .websiteUrlController.text,
serviceName: controller
.serviceNameController.text,
size: 48, size: 48,
), ),
), ),
@@ -134,10 +136,13 @@ class DetailHeaderSection extends StatelessWidget {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
controller.displayName ?? controller.serviceNameController.text, controller.displayName ??
controller
.serviceNameController.text,
style: const TextStyle( style: const TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@@ -154,12 +159,18 @@ class DetailHeaderSection extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
AppLocalizations.of(context).billingCyclePayment.replaceAll('@', AppLocalizations.of(context)
_getLocalizedBillingCycle(context, controller.billingCycle)), .billingCyclePayment
.replaceAll(
'@',
_getLocalizedBillingCycle(
context,
controller.billingCycle)),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.8), color: Colors.white
.withValues(alpha: 0.8),
), ),
), ),
], ],
@@ -176,19 +187,29 @@ class DetailHeaderSection extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
_InfoColumn( _InfoColumn(
label: AppLocalizations.of(context).nextBillingDate, label: AppLocalizations.of(context)
value: AppLocalizations.of(context).formatDate(controller.nextBillingDate), .nextBillingDate,
value: AppLocalizations.of(context)
.formatDate(
controller.nextBillingDate),
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: () async { future: () async {
final locale = context.read<LocaleProvider>().locale.languageCode; final locale = context
.read<LocaleProvider>()
.locale
.languageCode;
final amount = double.tryParse( final amount = double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '') controller
) ?? 0; .monthlyCostController.text
return CurrencyUtil.formatAmountWithLocale( .replaceAll(',', '')) ??
0;
return CurrencyUtil
.formatAmountWithLocale(
amount, amount,
controller.currency, controller.currency,
locale, locale,
@@ -196,7 +217,8 @@ class DetailHeaderSection extends StatelessWidget {
}(), }(),
builder: (context, snapshot) { builder: (context, snapshot) {
return _InfoColumn( return _InfoColumn(
label: AppLocalizations.of(context).monthlyExpense, label: AppLocalizations.of(context)
.monthlyExpense,
value: snapshot.data ?? '-', value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end, alignment: CrossAxisAlignment.end,
); );
@@ -219,6 +241,7 @@ class DetailHeaderSection extends StatelessWidget {
}, },
); );
} }
String _getLocalizedBillingCycle(BuildContext context, String cycle) { String _getLocalizedBillingCycle(BuildContext context, String cycle) {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
switch (cycle.toLowerCase()) { switch (cycle.toLowerCase()) {

View File

@@ -41,11 +41,11 @@ class DetailUrlSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1), color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
), ),
@@ -89,7 +89,7 @@ class DetailUrlSection extends StatelessWidget {
label: AppLocalizations.of(context).websiteUrl, label: AppLocalizations.of(context).websiteUrl,
hintText: AppLocalizations.of(context).urlExample, hintText: AppLocalizations.of(context).urlExample,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: Icon( prefixIcon: const Icon(
Icons.link_rounded, Icons.link_rounded,
color: AppColors.navyGray, color: AppColors.navyGray,
), ),
@@ -114,7 +114,7 @@ class DetailUrlSection extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon( const Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: AppColors.warningColor, color: AppColors.warningColor,
size: 20, size: 20,
@@ -122,7 +122,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
AppLocalizations.of(context).cancelGuide, AppLocalizations.of(context).cancelGuide,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy, color: AppColors.darkNavy,
@@ -133,7 +133,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
AppLocalizations.of(context).cancelServiceGuide, AppLocalizations.of(context).cancelServiceGuide,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -167,7 +167,7 @@ class DetailUrlSection extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( const Icon(
Icons.auto_fix_high_rounded, Icons.auto_fix_high_rounded,
color: AppColors.infoColor, color: AppColors.infoColor,
size: 20, size: 20,
@@ -176,7 +176,7 @@ class DetailUrlSection extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context).urlAutoMatchInfo, AppLocalizations.of(context).urlAutoMatchInfo,
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.darkNavy, color: AppColors.darkNavy,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -58,7 +58,8 @@ class EmptyStateWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.primaryColor.withValues(alpha: 0.3), color:
AppColors.primaryColor.withValues(alpha: 0.3),
spreadRadius: 0, spreadRadius: 0,
blurRadius: 16, blurRadius: 16,
offset: const Offset(0, 8), offset: const Offset(0, 8),
@@ -110,7 +111,7 @@ class EmptyStateWidget extends StatelessWidget {
}, },
child: Text( child: Text(
AppLocalizations.of(context).addSubscription, AppLocalizations.of(context).addSubscription,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.5, letterSpacing: 0.5,

View File

@@ -81,7 +81,8 @@ class _ExpandableFabState extends State<ExpandableFab>
animation: _expandAnimation, animation: _expandAnimation,
builder: (context, child) { builder: (context, child) {
return Container( return Container(
color: AppColors.shadowBlack.withValues(alpha: 3.75 * _expandAnimation.value), color: AppColors.shadowBlack
.withValues(alpha: 3.75 * _expandAnimation.value),
); );
}, },
), ),

View File

@@ -124,8 +124,11 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
_NavigationItem( _NavigationItem(
icon: Icons.settings_rounded, icon: Icons.settings_rounded,
label: AppLocalizations.of(context).settings, label: AppLocalizations.of(context).settings,
isSelected: PlatformHelper.isIOS ? widget.selectedIndex == 3 : widget.selectedIndex == 4, isSelected: PlatformHelper.isIOS
onTap: () => _onItemTapped(PlatformHelper.isIOS ? 3 : 4), ? widget.selectedIndex == 3
: widget.selectedIndex == 4,
onTap: () =>
_onItemTapped(PlatformHelper.isIOS ? 3 : 4),
), ),
], ],
), ),

View File

@@ -6,7 +6,8 @@ import 'themed_text.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
/// 글래스모피즘 효과가 적용된 통일된 앱바 /// 글래스모피즘 효과가 적용된 통일된 앱바
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget { class GlassmorphicAppBar extends StatelessWidget
implements PreferredSizeWidget {
final String title; final String title;
final List<Widget>? actions; final List<Widget>? actions;
final Widget? leading; final Widget? leading;
@@ -54,12 +55,16 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
(backgroundColor ?? (isDarkMode (backgroundColor ??
(isDarkMode
? AppColors.glassBackgroundDark ? AppColors.glassBackgroundDark
: AppColors.glassBackground)).withValues(alpha: opacity), : AppColors.glassBackground))
(backgroundColor ?? (isDarkMode .withValues(alpha: opacity),
(backgroundColor ??
(isDarkMode
? AppColors.glassSurfaceDark ? AppColors.glassSurfaceDark
: AppColors.glassSurface)).withValues(alpha: opacity * 0.8), : AppColors.glassSurface))
.withValues(alpha: opacity * 0.8),
], ],
), ),
border: Border( border: Border(
@@ -81,7 +86,9 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
child: SizedBox( child: SizedBox(
height: kToolbarHeight, height: kToolbarHeight,
child: NavigationToolbar( child: NavigationToolbar(
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null) leading: leading ??
(automaticallyImplyLeading &&
(canPop || onBackPressed != null)
? _buildBackButton(context) ? _buildBackButton(context)
: null), : null),
middle: _buildTitle(context), middle: _buildTitle(context),
@@ -92,7 +99,8 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
) )
: null, : null,
centerMiddle: centerTitle, centerMiddle: centerTitle,
middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing, middleSpacing:
titleSpacing ?? NavigationToolbar.kMiddleSpacing,
), ),
), ),
), ),
@@ -109,7 +117,8 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
Widget _buildBackButton(BuildContext context) { Widget _buildBackButton(BuildContext context) {
return IconButton( return IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded), icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ?? () { onPressed: onBackPressed ??
() {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
@@ -214,10 +223,12 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null) leading: leading ??
(automaticallyImplyLeading && (canPop || onBackPressed != null)
? IconButton( ? IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded), icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ?? () { onPressed: onBackPressed ??
() {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
@@ -230,7 +241,8 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
final top = constraints.biggest.height; final top = constraints.biggest.height;
final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top; final isCollapsed =
top <= kToolbarHeight + MediaQuery.of(context).padding.top;
return FlexibleSpaceBar( return FlexibleSpaceBar(
title: isCollapsed title: isCollapsed
@@ -244,7 +256,8 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
) )
: null, : null,
centerTitle: centerTitle, centerTitle: centerTitle,
titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16), titlePadding:
const EdgeInsets.only(left: 16, bottom: 16, right: 16),
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -260,10 +273,12 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
colors: [ colors: [
(isDarkMode (isDarkMode
? AppColors.glassBackgroundDark ? AppColors.glassBackgroundDark
: AppColors.glassBackground).withValues(alpha: opacity), : AppColors.glassBackground)
.withValues(alpha: opacity),
(isDarkMode (isDarkMode
? AppColors.glassSurfaceDark ? AppColors.glassSurfaceDark
: AppColors.glassSurface).withValues(alpha: opacity * 0.8), : AppColors.glassSurface)
.withValues(alpha: opacity * 0.8),
], ],
), ),
border: Border( border: Border(

View File

@@ -80,11 +80,14 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
final currentScroll = _scrollController!.position.pixels; final currentScroll = _scrollController!.position.pixels;
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김 // 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) { if (currentScroll > 50 &&
_scrollController!.position.userScrollDirection ==
ScrollDirection.reverse) {
if (_isFloatingNavBarVisible) { if (_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = false); setState(() => _isFloatingNavBarVisible = false);
} }
} else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) { } else if (_scrollController!.position.userScrollDirection ==
ScrollDirection.forward) {
if (!_isFloatingNavBarVisible) { if (!_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = true); setState(() => _isFloatingNavBarVisible = true);
} }
@@ -159,7 +162,9 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, 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<GlassmorphicScaffold>
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _particleController, animation: _particleController,
builder: (context, child) { builder: (context, child) {
final media = MediaQuery.maybeOf(context);
final reduce = media?.disableAnimations ?? false;
final count = reduce ? 10 : 30;
return CustomPaint( return CustomPaint(
painter: ParticlePainter( painter: ParticlePainter(
animation: _particleController, animation: _particleController,
particleCount: 30, particleCount: count,
), ),
); );
}, },
@@ -273,7 +281,9 @@ class WavePainter extends CustomPainter {
path.moveTo(0, size.height); path.moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) { for (double x = 0; x <= size.width; x++) {
final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 + final y =
math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) *
20 +
size.height * 0.5; size.height * 0.5;
path.lineTo(x, y); path.lineTo(x, y);
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/logger.dart';
import 'dart:ui'; import 'dart:ui';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'themed_text.dart'; import 'themed_text.dart';
@@ -56,7 +57,8 @@ class GlassmorphismCard extends StatelessWidget {
padding: padding, padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor ?? AppColors.glassCard, color: backgroundColor ?? AppColors.glassCard,
gradient: gradient ?? LinearGradient( gradient: gradient ??
LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: isDarkMode colors: isDarkMode
@@ -64,18 +66,21 @@ class GlassmorphismCard extends StatelessWidget {
: AppColors.glassGradient, : AppColors.glassGradient,
), ),
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
border: border ?? Border.all( border: border ??
Border.all(
color: isDarkMode color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3) ? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder, : AppColors.glassBorder,
width: 1, width: 1,
), ),
boxShadow: boxShadow ?? [ boxShadow: boxShadow ??
BoxShadow( [
color: AppColors.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08) const BoxShadow(
color: AppColors
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20, blurRadius: 20,
spreadRadius: -5, spreadRadius: -5,
offset: const Offset(0, 10), offset: Offset(0, 10),
), ),
], ],
), ),
@@ -119,7 +124,8 @@ class AnimatedGlassmorphismCard extends StatefulWidget {
}); });
@override @override
State<AnimatedGlassmorphismCard> createState() => _AnimatedGlassmorphismCardState(); State<AnimatedGlassmorphismCard> createState() =>
_AnimatedGlassmorphismCardState();
} }
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard> class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
@@ -195,7 +201,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
_handleTapUp(details); _handleTapUp(details);
// onTap 콜백 실행 // onTap 콜백 실행
if (widget.onTap != null) { if (widget.onTap != null) {
print('[AnimatedGlassmorphismCard] onTap 콜백 실행'); Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!(); widget.onTap!();
} }
}, },

View File

@@ -52,8 +52,10 @@ class HomeContent extends StatelessWidget {
} }
// 카테고리별 구독 구분 // 카테고리별 구독 구분
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false); final categoryProvider =
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions( Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions =
SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions, provider.subscriptions,
categoryProvider, categoryProvider,
context, context,
@@ -120,7 +122,8 @@ class HomeContent extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length), AppLocalizations.of(context)
.subscriptionCount(provider.subscriptions.length),
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@@ -88,8 +88,9 @@ class MainScreenSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
AppLocalizations.of(context).monthlyTotalSubscriptionCost, AppLocalizations.of(context)
style: TextStyle( .monthlyTotalSubscriptionCost,
style: const TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 15, fontSize: 15,
@@ -99,9 +100,12 @@ class MainScreenSummaryCard extends StatelessWidget {
// 환율 정보 표시 (영어 사용자는 표시 안함) // 환율 정보 표시 (영어 사용자는 표시 안함)
if (locale != 'en') if (locale != 'en')
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfoForLocale(locale), future:
CurrencyUtil.getExchangeRateInfoForLocale(
locale),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) { if (snapshot.hasData &&
snapshot.data!.isNotEmpty) {
return Container( return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@@ -116,7 +120,9 @@ class MainScreenSummaryCard extends StatelessWidget {
), ),
), ),
child: Text( child: Text(
AppLocalizations.of(context).exchangeRateDisplay.replaceAll('@', snapshot.data!), AppLocalizations.of(context)
.exchangeRateDisplay
.replaceAll('@', snapshot.data!),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -133,7 +139,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
// 월별 총 비용 표시 (언어별 기본 통화) // 월별 총 비용 표시 (언어별 기본 통화)
FutureBuilder<double>( FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency( future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions, provider.subscriptions,
locale, locale,
), ),
@@ -142,7 +149,10 @@ class MainScreenSummaryCard extends StatelessWidget {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
final monthlyCost = snapshot.data!; final monthlyCost = snapshot.data!;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2; final decimals = (defaultCurrency == 'KRW' ||
defaultCurrency == 'JPY')
? 0
: 2;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.baseline, crossAxisAlignment: CrossAxisAlignment.baseline,
@@ -150,9 +160,13 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [ children: [
Text( Text(
NumberFormat.currency( NumberFormat.currency(
locale: defaultCurrency == 'KRW' ? 'ko_KR' : locale: defaultCurrency == 'KRW'
defaultCurrency == 'JPY' ? 'ja_JP' : ? 'ko_KR'
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', : defaultCurrency == 'JPY'
? 'ja_JP'
: defaultCurrency == 'CNY'
? 'zh_CN'
: 'en_US',
symbol: '', symbol: '',
decimalDigits: decimals, decimalDigits: decimals,
).format(monthlyCost), ).format(monthlyCost),
@@ -179,7 +193,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
// 연간 비용 및 총 구독 수 표시 // 연간 비용 및 총 구독 수 표시
FutureBuilder<double>( FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency( future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions, provider.subscriptions,
locale, locale,
), ),
@@ -189,26 +204,36 @@ class MainScreenSummaryCard extends StatelessWidget {
} }
final monthlyCost = snapshot.data!; final monthlyCost = snapshot.data!;
final yearlyCost = monthlyCost * 12; final yearlyCost = monthlyCost * 12;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2; final decimals = (defaultCurrency == 'KRW' ||
defaultCurrency == 'JPY')
? 0
: 2;
return Row( return Row(
children: [ children: [
_buildInfoBox( _buildInfoBox(
context, context,
title: AppLocalizations.of(context).estimatedAnnualCost, title: AppLocalizations.of(context)
value: '${NumberFormat.currency( .estimatedAnnualCost,
locale: defaultCurrency == 'KRW' ? 'ko_KR' : value: NumberFormat.currency(
defaultCurrency == 'JPY' ? 'ja_JP' : locale: defaultCurrency == 'KRW'
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', ? 'ko_KR'
: defaultCurrency == 'JPY'
? 'ja_JP'
: defaultCurrency == 'CNY'
? 'zh_CN'
: 'en_US',
symbol: currencySymbol, symbol: currencySymbol,
decimalDigits: decimals, decimalDigits: decimals,
).format(yearlyCost)}', ).format(yearlyCost),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildInfoBox( _buildInfoBox(
context, context,
title: AppLocalizations.of(context).totalSubscriptionServices, title: AppLocalizations.of(context)
value: '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}', .totalSubscriptionServices,
value:
'$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
), ),
], ],
); );
@@ -255,8 +280,9 @@ class MainScreenSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
AppLocalizations.of(context).eventDiscountActive, AppLocalizations.of(context)
style: TextStyle( .eventDiscountActive,
style: const TextStyle(
color: AppColors color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 11, fontSize: 11,
@@ -266,7 +292,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
// 이벤트 절약액 표시 (언어별 기본 통화) // 이벤트 절약액 표시 (언어별 기본 통화)
FutureBuilder<double>( FutureBuilder<double>(
future: CurrencyUtil.calculateTotalEventSavingsInDefaultCurrency( future: CurrencyUtil
.calculateTotalEventSavingsInDefaultCurrency(
provider.subscriptions, provider.subscriptions,
locale, locale,
), ),
@@ -275,15 +302,24 @@ class MainScreenSummaryCard extends StatelessWidget {
return const SizedBox(); return const SizedBox();
} }
final eventSavings = snapshot.data!; final eventSavings = snapshot.data!;
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2; final decimals =
(defaultCurrency == 'KRW' ||
defaultCurrency == 'JPY')
? 0
: 2;
return Row( return Row(
children: [ children: [
Text( Text(
NumberFormat.currency( NumberFormat.currency(
locale: defaultCurrency == 'KRW' ? 'ko_KR' : locale: defaultCurrency == 'KRW'
defaultCurrency == 'JPY' ? 'ja_JP' : ? 'ko_KR'
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US', : defaultCurrency == 'JPY'
? 'ja_JP'
: defaultCurrency ==
'CNY'
? 'zh_CN'
: 'en_US',
symbol: currencySymbol, symbol: currencySymbol,
decimalDigits: decimals, decimalDigits: decimals,
).format(eventSavings), ).format(eventSavings),
@@ -337,7 +373,7 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: TextStyle( style: const TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -44,7 +44,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
} }
_nativeAd = NativeAd( _nativeAd = NativeAd(
adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요 adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함 factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함
request: const AdRequest(), request: const AdRequest(),
listener: NativeAdListener( listener: NativeAdListener(
@@ -63,15 +63,15 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
)..load(); )..load();
} }
/// 테스트 광고 단위 ID 반환 함수 /// 광고 단위 ID 반환 함수
/// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용 /// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용
String _testAdUnitId() { String _testAdUnitId() {
if (Platform.isAndroid) { if (Platform.isAndroid) {
// Android 테스트 네이티브 광고 ID // Android 네이티브 광고 ID
return 'ca-app-pub-3940256099942544/2247696110'; return 'ca-app-pub-6691216385521068/4512709971';
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
// iOS 테스트 네이티브 광고 ID // iOS 네이티브 광고 ID
return 'ca-app-pub-3940256099942544/3986624511'; return 'ca-app-pub-6691216385521068/4512709971';
} }
return ''; return '';
} }

View File

@@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CircularProgressIndicator( const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor), valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -43,7 +43,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
void initState() { void initState() {
super.initState(); super.initState();
// URL 필드 자동 설정 // 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!; widget.websiteUrlController.text = widget.subscription.websiteUrl!;
} }
} }
@@ -256,7 +257,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
const SizedBox(height: 8), const SizedBox(height: 8),
CategorySelector( CategorySelector(
categories: categoryProvider.categories, categories: categoryProvider.categories,
selectedCategoryId: widget.selectedCategoryId ?? widget.subscription.category, selectedCategoryId:
widget.selectedCategoryId ?? widget.subscription.category,
onChanged: widget.onCategoryChanged, onChanged: widget.onCategoryChanged,
baseColor: _getCategoryColor(categoryProvider), baseColor: _getCategoryColor(categoryProvider),
isGlassmorphism: true, isGlassmorphism: true,
@@ -304,7 +306,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
} }
Color? _getCategoryColor(CategoryProvider categoryProvider) { Color? _getCategoryColor(CategoryProvider categoryProvider) {
final categoryId = widget.selectedCategoryId ?? widget.subscription.category; final categoryId =
widget.selectedCategoryId ?? widget.subscription.category;
if (categoryId == null) return null; if (categoryId == null) return null;
final category = categoryProvider.getCategoryById(categoryId); final category = categoryProvider.getCategoryById(categoryId);

View File

@@ -325,8 +325,8 @@ class _RippleAnimationState extends State<RippleAnimation>
height: 100 + 200 * _animation.value, height: 100 + 200 * _animation.value,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: widget.rippleColor.withValues(alpha: color: widget.rippleColor.withValues(
(1 - _animation.value) * 0.3, alpha: (1 - _animation.value) * 0.3,
), ),
), ),
); );

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
@@ -60,7 +59,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} }
} }
@override @override
void didUpdateWidget(SubscriptionCard oldWidget) { void didUpdateWidget(SubscriptionCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -203,7 +201,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
daysUntilNext = 7; // 다음 주 같은 요일 daysUntilNext = 7; // 다음 주 같은 요일
} }
if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday; if (daysUntilNext == 0) {
return AppLocalizations.of(context).paymentDueToday;
}
return AppLocalizations.of(context).paymentDueInDays(daysUntilNext); return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
} }
@@ -234,15 +234,15 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} }
final categoryProvider = context.watch<CategoryProvider>(); final categoryProvider = context.watch<CategoryProvider>();
final category = categoryProvider.getCategoryById(widget.subscription.categoryId!); final category =
categoryProvider.getCategoryById(widget.subscription.categoryId!);
if (category == null) { if (category == null) {
return AppColors.blueGradient; return AppColors.blueGradient;
} }
final categoryColor = Color( final categoryColor =
int.parse(category.color.replaceAll('#', '0xFF')) Color(int.parse(category.color.replaceAll('#', '0xFF')));
);
return [ return [
categoryColor, categoryColor,
@@ -301,8 +301,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
borderRadius: 16, borderRadius: 16,
blur: _isHovering ? 15 : 10, blur: _isHovering ? 15 : 10,
width: double.infinity, // 전체 너비를 차지하도록 설정 width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ?? () async { onTap: widget.onTap ??
print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}'); () async {
// ignore: use_build_context_synchronously
await AppNavigator.toDetail(context, widget.subscription); await AppNavigator.toDetail(context, widget.subscription);
}, },
child: Column( child: Column(
@@ -349,17 +350,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: mainAxisAlignment: MainAxisAlignment.spaceBetween,
MainAxisAlignment.spaceBetween,
children: [ children: [
// 서비스명 // 서비스명
Flexible( Flexible(
child: Text( child: Text(
_displayName ?? widget.subscription.serviceName, _displayName ??
widget.subscription.serviceName,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 18, fontSize: 18,
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트 color: AppColors
.darkNavy, // color.md 가이드: 메인 텍스트
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -371,7 +373,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 이벤트 배지 // 이벤트 배지
if (widget.subscription.isCurrentlyInEvent) ...[ if (widget
.subscription.isCurrentlyInEvent) ...[
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@@ -384,8 +387,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Color(0xFFFF8787), Color(0xFFFF8787),
], ],
), ),
borderRadius: borderRadius: BorderRadius.circular(12),
BorderRadius.circular(12),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -418,19 +420,21 @@ class _SubscriptionCardState extends State<SubscriptionCard>
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceColorAlt, color: AppColors.surfaceColorAlt,
borderRadius: borderRadius: BorderRadius.circular(12),
BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.borderColor, color: AppColors.borderColor,
width: 0.5, width: 0.5,
), ),
), ),
child: Text( child: Text(
AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle), AppLocalizations.of(context)
.getBillingCycleName(
widget.subscription.billingCycle),
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors
.navyGray, // color.md 가이드: 서브 텍스트
), ),
), ),
), ),
@@ -443,8 +447,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 가격 정보 // 가격 정보
Row( Row(
mainAxisAlignment: mainAxisAlignment: MainAxisAlignment.spaceBetween,
MainAxisAlignment.spaceBetween,
children: [ children: [
// 가격 표시 (이벤트 가격 반영) // 가격 표시 (이벤트 가격 반영)
// 가격 표시 (언어별 통화) // 가격 표시 (언어별 통화)
@@ -455,7 +458,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return const SizedBox(); return const SizedBox();
} }
if (widget.subscription.isCurrentlyInEvent && snapshot.data!.contains('|')) { if (widget.subscription.isCurrentlyInEvent &&
snapshot.data!.contains('|')) {
final prices = snapshot.data!.split('|'); final prices = snapshot.data!.split('|');
return Row( return Row(
children: [ children: [
@@ -465,7 +469,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.navyGray, color: AppColors.navyGray,
decoration: TextDecoration.lineThrough, decoration:
TextDecoration.lineThrough,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -485,7 +490,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: widget.subscription.isCurrentlyInEvent color: widget
.subscription.isCurrentlyInEvent
? const Color(0xFFFF6B6B) ? const Color(0xFFFF6B6B)
: AppColors.primaryColor, : AppColors.primaryColor,
), ),
@@ -506,18 +512,15 @@ class _SubscriptionCardState extends State<SubscriptionCard>
.withValues(alpha: 0.1) .withValues(alpha: 0.1)
: AppColors.successColor : AppColors.successColor
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
borderRadius: borderRadius: BorderRadius.circular(12),
BorderRadius.circular(12),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isNearBilling isNearBilling
? Icons ? Icons.access_time_filled_rounded
.access_time_filled_rounded : Icons.check_circle_rounded,
: Icons
.check_circle_rounded,
size: 12, size: 12,
color: isNearBilling color: isNearBilling
? AppColors.warningColor ? AppColors.warningColor
@@ -552,7 +555,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF6B6B).withValues(alpha: 0.1), color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -566,7 +570,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
const SizedBox(width: 4), const SizedBox(width: 4),
// 이벤트 절약액 표시 (언어별 통화) // 이벤트 절약액 표시 (언어별 통화)
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.formatEventSavingsWithLocale( future: CurrencyUtil
.formatEventSavingsWithLocale(
widget.subscription, widget.subscription,
localeProvider.locale.languageCode, localeProvider.locale.languageCode,
), ),
@@ -589,12 +594,17 @@ class _SubscriptionCardState extends State<SubscriptionCard>
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// 이벤트 종료일까지 남은 일수 // 이벤트 종료일까지 남은 일수
if (widget.subscription.eventEndDate != null) ...[ if (widget.subscription.eventEndDate !=
null) ...[
Text( Text(
AppLocalizations.of(context).daysRemaining(widget.subscription.eventEndDate!.difference(DateTime.now()).inDays), AppLocalizations.of(context).daysRemaining(
widget.subscription.eventEndDate!
.difference(DateTime.now())
.inDays),
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트 color: AppColors
.navyGray, // color.md 가이드: 서브 텍스트
), ),
), ),
], ],

View File

@@ -12,6 +12,7 @@ import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart'; import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart'; import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯 /// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget { class SubscriptionListWidget extends StatelessWidget {
@@ -46,12 +47,17 @@ class SubscriptionListWidget extends StatelessWidget {
child: Consumer<CategoryProvider>( child: Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) { builder: (context, categoryProvider, child) {
return CategoryHeaderWidget( return CategoryHeaderWidget(
categoryName: categoryProvider.getLocalizedCategoryName(context, category), categoryName: categoryProvider.getLocalizedCategoryName(
context, category),
subscriptionCount: subscriptions.length, subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), totalCostUSD:
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'), totalCostKRW:
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'), _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY:
_calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY:
_calculateTotalByCurrency(subscriptions, 'CNY'),
); );
}, },
), ),
@@ -65,6 +71,7 @@ class SubscriptionListWidget extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
prototypeItem: const SizedBox(height: 156),
itemCount: subscriptions.length, itemCount: subscriptions.length,
itemBuilder: (context, subIndex) { itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록) // 각 구독의 지연값 계산 (순차적으로 나타나도록)
@@ -92,33 +99,45 @@ class SubscriptionListWidget extends StatelessWidget {
child: StaggeredAnimationItem( child: StaggeredAnimationItem(
index: subIndex, index: subIndex,
delay: const Duration(milliseconds: 50), delay: const Duration(milliseconds: 50),
child: RepaintBoundary(
child: SwipeableSubscriptionCard( child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex], subscription: subscriptions[subIndex],
onTap: () { onTap: () {
print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); Log.d(
AppNavigator.toDetail(context, subscriptions[subIndex]); '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(
context, subscriptions[subIndex]);
}, },
onDelete: () async { onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기 // 현재 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>( final localeProvider =
Provider.of<LocaleProvider>(
context, context,
listen: false, listen: false,
); );
final locale = localeProvider.locale.languageCode; final locale =
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName( localeProvider.locale.languageCode;
serviceName: subscriptions[subIndex].serviceName, final displayName =
await SubscriptionUrlMatcher
.getServiceDisplayName(
serviceName:
subscriptions[subIndex].serviceName,
locale: locale, locale: locale,
); );
// 삭제 확인 다이얼로그 표시 // 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show( if (!context.mounted) return;
final shouldDelete =
await DeleteConfirmationDialog.show(
context: context, context: context,
serviceName: displayName, serviceName: displayName,
); );
if (!context.mounted) return;
if (shouldDelete && context.mounted) { if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행 // 사용자가 확인한 경우에만 삭제 진행
final provider = Provider.of<SubscriptionProvider>( final provider =
Provider.of<SubscriptionProvider>(
context, context,
listen: false, listen: false,
); );
@@ -129,7 +148,8 @@ class SubscriptionListWidget extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context).subscriptionDeleted(displayName), message: AppLocalizations.of(context)
.subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded, icon: Icons.delete_forever_rounded,
); );
} }
@@ -138,6 +158,7 @@ class SubscriptionListWidget extends StatelessWidget {
), ),
), ),
), ),
),
); );
}, },
), ),
@@ -152,7 +173,8 @@ class SubscriptionListWidget extends StatelessWidget {
} }
/// 특정 통화의 총 합계를 계산합니다. /// 특정 통화의 총 합계를 계산합니다.
double _calculateTotalByCurrency(List<SubscriptionModel> subscriptions, String currency) { double _calculateTotalByCurrency(
List<SubscriptionModel> subscriptions, String currency) {
return subscriptions return subscriptions
.where((sub) => sub.currency == currency) .where((sub) => sub.currency == currency)
.fold(0.0, (sum, sub) => sum + sub.monthlyCost); .fold(0.0, (sum, sub) => sum + sub.monthlyCost);

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/haptic_feedback_helper.dart'; import '../utils/haptic_feedback_helper.dart';
import 'subscription_card.dart'; import 'subscription_card.dart';
@@ -29,7 +28,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
static const double _tapTolerance = 20.0; // 탭 허용 범위 static const double _tapTolerance = 20.0; // 탭 허용 범위
static const double _actionThresholdPercent = 0.15; static const double _actionThresholdPercent = 0.15;
static const double _deleteThresholdPercent = 0.40; static const double _deleteThresholdPercent = 0.40;
static const int _tapDurationMs = 500;
static const double _velocityThreshold = 800.0; static const double _velocityThreshold = 800.0;
// static const double _animationDuration = 300.0; // static const double _animationDuration = 300.0;
@@ -39,8 +37,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 추적 // 제스처 추적
Offset? _startPosition; Offset? _startPosition;
DateTime? _startTime; // 제스처 관련 보조 변수(간소화)
bool _isValidTap = true;
// 상태 관리 // 상태 관리
double _currentOffset = 0; double _currentOffset = 0;
@@ -95,8 +92,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 핸들러 // 제스처 핸들러
void _handlePanStart(DragStartDetails details) { void _handlePanStart(DragStartDetails details) {
_startPosition = details.localPosition; _startPosition = details.localPosition;
_startTime = DateTime.now();
_isValidTap = true;
_hapticTriggered = false; _hapticTriggered = false;
_controller.stop(); _controller.stop();
} }
@@ -104,12 +99,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
void _handlePanUpdate(DragUpdateDetails details) { void _handlePanUpdate(DragUpdateDetails details) {
final currentPosition = details.localPosition; final currentPosition = details.localPosition;
final delta = currentPosition.dx - _startPosition!.dx; final delta = currentPosition.dx - _startPosition!.dx;
final distance = (currentPosition - _startPosition!).distance; // 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
if (distance > _tapTolerance) {
_isValidTap = false;
}
// 카드 이동 // 카드 이동
setState(() { setState(() {
@@ -129,14 +119,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
} }
// 헬퍼 메서드 // 헬퍼 메서드
void _processTap() { // 탭 처리는 SubscriptionCard에서 수행
print('[SwipeableSubscriptionCard] _processTap 호출됨');
if (widget.onTap != null) {
print('[SwipeableSubscriptionCard] onTap 콜백 실행');
widget.onTap!();
}
_animateToOffset(0);
}
void _processSwipe(double velocity) { void _processSwipe(double velocity) {
final extent = _currentOffset.abs(); final extent = _currentOffset.abs();
@@ -258,7 +241,8 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
angle: _currentOffset / 2000, angle: _currentOffset / 2000,
child: SubscriptionCard( child: SubscriptionCard(
subscription: widget.subscription, subscription: widget.subscription,
onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함 onTap: widget
.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함
), ),
), ),
), ),

View File

@@ -35,7 +35,8 @@ class ThemedText extends StatelessWidget {
}); });
/// 배경 밝기에 따른 텍스트 색상 결정 /// 배경 밝기에 따른 텍스트 색상 결정
static Color getContrastColor(BuildContext context, { static Color getContrastColor(
BuildContext context, {
bool forceLight = false, bool forceLight = false,
bool forceDark = false, bool forceDark = false,
}) { }) {
@@ -58,21 +59,22 @@ class ThemedText extends StatelessWidget {
/// 글래스모피즘 컨텍스트인지 확인 /// 글래스모피즘 컨텍스트인지 확인
static bool _isGlassmorphicContext(BuildContext context) { static bool _isGlassmorphicContext(BuildContext context) {
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인 // 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
final glassmorphic = context.findAncestorWidgetOfExactType<GlassmorphicIndicator>(); final glassmorphic =
context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
return glassmorphic != null; return glassmorphic != null;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textColor = color ?? getContrastColor( final textColor = color ??
getContrastColor(
context, context,
forceLight: forceLight, forceLight: forceLight,
forceDark: forceDark, forceDark: forceDark,
); );
final finalColor = opacity != null final finalColor =
? textColor.withValues(alpha: opacity!) opacity != null ? textColor.withValues(alpha: opacity!) : textColor;
: textColor;
final defaultStyle = DefaultTextStyle.of(context).style; final defaultStyle = DefaultTextStyle.of(context).style;

View File

@@ -104,8 +104,6 @@ class FaviconCache {
// 구글 파비콘 API 서비스 // 구글 파비콘 API 서비스
class GoogleFaviconService { class GoogleFaviconService {
// 구글 파비콘 API URL 생성 // 구글 파비콘 API URL 생성
static String getFaviconUrl(String domain, int size) { static String getFaviconUrl(String domain, int size) {
final directUrl = final directUrl =
@@ -137,7 +135,8 @@ class GoogleFaviconService {
static String getBase64PlaceholderIcon(String serviceName, Color color) { static String getBase64PlaceholderIcon(String serviceName, Color color) {
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시) // 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?'; 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 디코딩 후 이미지 로드 시 문제 발생) // 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생)
final svgContent = final svgContent =
@@ -568,7 +567,8 @@ class _WebsiteIconState extends State<WebsiteIcon>
boxShadow: widget.isHovered boxShadow: widget.isHovered
? [ ? [
BoxShadow( BoxShadow(
color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값 color:
_getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값
blurRadius: 12, blurRadius: 12,
spreadRadius: 0, spreadRadius: 0,
offset: const Offset(0, 4), offset: const Offset(0, 4),

19
scripts/check.sh Executable file
View File

@@ -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."

8
scripts/fix.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
echo "==> Formatting code"
dart format .
echo "Formatting complete."

View File

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

View File

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