Compare commits
13 Commits
9f1d29c99d
...
5b72fa196c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b72fa196c | ||
|
|
6cd3b9720f | ||
|
|
5a7ef8039e | ||
|
|
10069a1800 | ||
|
|
b034f60510 | ||
|
|
eb6691ce6a | ||
|
|
10491af55b | ||
|
|
4673aed281 | ||
|
|
84b3fdd530 | ||
|
|
d37f66d526 | ||
|
|
d1a6cb9fe3 | ||
|
|
f812d4b9fd | ||
|
|
2a90e7c377 |
13
.claude/agents/codex.md
Normal file
13
.claude/agents/codex.md
Normal 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
31
.github/workflows/flutter_ci.yml
vendored
Normal 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
69
AGENTS.md
Normal 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 multi‑step tasks, maintain an update_plan with exactly one in_progress step.
|
||||
- Language: 기본적으로 한국어로 응답합니다. (필요 시 코드/로그/명령어는 원문 유지)
|
||||
|
||||
Coding Standards
|
||||
- Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline).
|
||||
- Style/format: use `dart format .` and keep changes minimal. Avoid one‑letter variable names; avoid inline comments unless requested.
|
||||
- Structure: follow existing file/module patterns and naming. Do not introduce new frameworks or architectural shifts without approval.
|
||||
- Tests: add or update tests when behavior changes or bugs are fixed (if feasible). Keep tests scoped to the change.
|
||||
|
||||
Validation
|
||||
- Always run local checks via `scripts/check.sh` before proposing completion:
|
||||
- formatting check: `dart format --set-exit-if-changed .`
|
||||
- static analysis: `flutter analyze`
|
||||
- unit/widget tests: `flutter test` (ok if none exist)
|
||||
- UI changes: include brief description of visual impact; screenshots if readily available by the user.
|
||||
|
||||
Sensitive Areas (require explicit approval)
|
||||
- Android/iOS/macOS build configs, signing, bundle identifiers, Gradle/Kotlin/Swift project settings.
|
||||
- Dependency graph changes (pubspec.yaml add/remove/upgrade).
|
||||
- Network access, calling external APIs, or adding secrets.
|
||||
|
||||
Operational Conventions
|
||||
- Branch naming: `codex/<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 / non‑goals:
|
||||
- Repro or commands:
|
||||
|
||||
Done When
|
||||
- [ ] Behavior verified (`scripts/check.sh` passes)
|
||||
- [ ] Tests/docs updated if applicable
|
||||
---
|
||||
|
||||
Commands
|
||||
- Lint/analyze/tests: `scripts/check.sh`
|
||||
- Auto‑format: `scripts/fix.sh`
|
||||
|
||||
References & External Facts
|
||||
- Prefer official docs and code‑local references. If citing sources, include plain URLs or file paths in PR descriptions (avoid footnote citation syntaxes).
|
||||
|
||||
Notes from ~/.claude (adapted)
|
||||
- Few‑shot examples improve accuracy; include small before/after or sample input→output when helpful.
|
||||
- Use structured thinking internally; present only concise, actionable outputs here.
|
||||
@@ -1,6 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:label="구독 관리"
|
||||
@@ -33,6 +32,10 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -217,6 +217,17 @@
|
||||
"enterAmount": "Enter amount",
|
||||
"invalidAmount": "Please enter a valid amount",
|
||||
"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": {
|
||||
"appTitle": "디지털 월세 관리자",
|
||||
@@ -436,6 +447,17 @@
|
||||
"enterAmount": "금액을 입력하세요",
|
||||
"invalidAmount": "올바른 금액을 입력해주세요",
|
||||
"featureComingSoon": "이 기능은 곧 출시됩니다"
|
||||
,
|
||||
"smsPermissionTitle": "SMS 권한 요청",
|
||||
"smsPermissionReasonTitle": "이유",
|
||||
"smsPermissionReasonBody": "문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다. 모든 처리는 기기 내에서만 이루어집니다.",
|
||||
"smsPermissionScopeTitle": "수집 범위",
|
||||
"smsPermissionScopeBody": "결제 관련 문자 메시지의 패턴(서비스명/금액/날짜)만 로컬에서 처리하며, 외부로 전송하지 않습니다.",
|
||||
"permanentlyDeniedMessage": "권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.",
|
||||
"openSettings": "설정 열기",
|
||||
"later": "나중에 하기",
|
||||
"requesting": "요청 중...",
|
||||
"smsPermissionLabel": "SMS 권한"
|
||||
},
|
||||
"ja": {
|
||||
"appTitle": "デジタル月額管理者",
|
||||
|
||||
70
doc/agents/codex_prompt_templates.md
Normal file
70
doc/agents/codex_prompt_templates.md
Normal 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 / Non‑goals: <limits>
|
||||
|
||||
Done When
|
||||
- scripts/check.sh passes; behavior verified via repro
|
||||
- Tests/docs updated if applicable
|
||||
---
|
||||
|
||||
Small Feature Prompt
|
||||
---
|
||||
Context
|
||||
- Goal: <user‑visible behavior>
|
||||
- Entry points: <screens/routes/widgets>
|
||||
- Data/State impact: <provider/models/side effects>
|
||||
- Constraints / Non‑goals: <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 follow‑ups
|
||||
---
|
||||
|
||||
@@ -47,5 +47,8 @@
|
||||
<true/>
|
||||
<key>NSMessageUsageDescription</key>
|
||||
<string>구독 결제 정보를 자동으로 추가하기 위해 SMS 접근이 필요합니다.</string>
|
||||
<!-- Google AdMob App ID -->
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-6691216385521068~6638409932</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../services/sms_service.dart';
|
||||
@@ -158,12 +157,14 @@ class AddSubscriptionController {
|
||||
serviceNameController.text = serviceInfo.serviceName;
|
||||
|
||||
// 카테고리 자동 선택
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categoryProvider =
|
||||
Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categories = categoryProvider.categories;
|
||||
|
||||
// 카테고리 ID로 매칭
|
||||
final matchedCategory = categories.firstWhere(
|
||||
(cat) => cat.name == serviceInfo.categoryNameKr ||
|
||||
(cat) =>
|
||||
cat.name == serviceInfo.categoryNameKr ||
|
||||
cat.name == serviceInfo.categoryNameEn,
|
||||
orElse: () => categories.first,
|
||||
);
|
||||
@@ -174,12 +175,14 @@ class AddSubscriptionController {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showSuccess(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName),
|
||||
message: AppLocalizations.of(context)
|
||||
.serviceRecognized(serviceInfo.serviceName),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
|
||||
}
|
||||
}
|
||||
@@ -187,7 +190,8 @@ class AddSubscriptionController {
|
||||
|
||||
/// 카테고리 자동 선택
|
||||
void autoSelectCategory() {
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categoryProvider =
|
||||
Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categories = categoryProvider.categories;
|
||||
|
||||
final serviceName = serviceNameController.text.toLowerCase();
|
||||
@@ -312,9 +316,11 @@ class AddSubscriptionController {
|
||||
|
||||
if (smsContent.isNotEmpty) {
|
||||
try {
|
||||
serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
|
||||
serviceInfo =
|
||||
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
|
||||
}
|
||||
}
|
||||
@@ -327,11 +333,13 @@ class AddSubscriptionController {
|
||||
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 matchedCategory = categories.firstWhere(
|
||||
(cat) => cat.name == serviceInfo!.categoryNameKr ||
|
||||
(cat) =>
|
||||
cat.name == serviceInfo!.categoryNameKr ||
|
||||
cat.name == serviceInfo.categoryNameEn,
|
||||
orElse: () => categories.first,
|
||||
);
|
||||
@@ -396,7 +404,8 @@ class AddSubscriptionController {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()),
|
||||
message: AppLocalizations.of(context)
|
||||
.smsScanErrorWithMessage(e.toString()),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -421,9 +430,8 @@ class AddSubscriptionController {
|
||||
// 이벤트 가격 파싱
|
||||
double? eventPrice;
|
||||
if (isEventActive && eventPriceController.text.isNotEmpty) {
|
||||
eventPrice = double.tryParse(
|
||||
eventPriceController.text.replaceAll(',', '')
|
||||
);
|
||||
eventPrice =
|
||||
double.tryParse(eventPriceController.text.replaceAll(',', ''));
|
||||
}
|
||||
|
||||
await Provider.of<SubscriptionProvider>(context, listen: false)
|
||||
@@ -452,7 +460,8 @@ class AddSubscriptionController {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
|
||||
message:
|
||||
AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +140,12 @@ class DetailScreenController extends ChangeNotifier {
|
||||
/// 초기화
|
||||
void initialize({required TickerProvider vsync}) {
|
||||
// Text Controllers 초기화
|
||||
serviceNameController = TextEditingController(text: subscription.serviceName);
|
||||
monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString());
|
||||
websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? '');
|
||||
serviceNameController =
|
||||
TextEditingController(text: subscription.serviceName);
|
||||
monthlyCostController =
|
||||
TextEditingController(text: subscription.monthlyCost.toString());
|
||||
websiteUrlController =
|
||||
TextEditingController(text: subscription.websiteUrl ?? '');
|
||||
eventPriceController = TextEditingController();
|
||||
|
||||
// Form State 초기화
|
||||
@@ -261,10 +264,12 @@ class DetailScreenController extends ChangeNotifier {
|
||||
if (_currency == 'KRW') {
|
||||
// 원화는 소수점 없이 표시
|
||||
final intValue = subscription.monthlyCost.toInt();
|
||||
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
|
||||
monthlyCostController.text =
|
||||
NumberFormat.decimalPattern().format(intValue);
|
||||
} else {
|
||||
// 달러는 소수점 2자리까지 표시
|
||||
monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost);
|
||||
monthlyCostController.text =
|
||||
NumberFormat('#,##0.00').format(subscription.monthlyCost);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +280,8 @@ class DetailScreenController extends ChangeNotifier {
|
||||
|
||||
/// 카테고리 자동 선택
|
||||
void autoSelectCategory() {
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categoryProvider =
|
||||
Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categories = categoryProvider.categories;
|
||||
|
||||
final serviceName = serviceNameController.text.toLowerCase();
|
||||
@@ -377,7 +383,8 @@ class DetailScreenController extends ChangeNotifier {
|
||||
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
|
||||
String? websiteUrl = websiteUrlController.text;
|
||||
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;
|
||||
try {
|
||||
monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', ''));
|
||||
monthlyCost =
|
||||
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
||||
} catch (e) {
|
||||
// 파싱 오류 발생 시 기본값 사용
|
||||
monthlyCost = subscription.monthlyCost;
|
||||
@@ -393,7 +401,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
|
||||
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
|
||||
'${subscription.serviceName} → ${serviceNameController.text}, '
|
||||
'금액: ${subscription.monthlyCost} → $monthlyCost ${_currency}');
|
||||
'금액: $subscription.monthlyCost → $monthlyCost $_currency');
|
||||
|
||||
subscription.serviceName = serviceNameController.text;
|
||||
subscription.monthlyCost = monthlyCost;
|
||||
@@ -445,30 +453,35 @@ class DetailScreenController extends ChangeNotifier {
|
||||
Future<void> deleteSubscription() async {
|
||||
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 displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
||||
serviceName: subscription.serviceName,
|
||||
locale: locale,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
final shouldDelete = await DeleteConfirmationDialog.show(
|
||||
context: context,
|
||||
serviceName: displayName,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (!shouldDelete) return;
|
||||
|
||||
// 사용자가 확인한 경우에만 삭제 진행
|
||||
if (context.mounted) {
|
||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
final provider =
|
||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
await provider.deleteSubscription(subscription.id);
|
||||
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).subscriptionDeleted(displayName),
|
||||
message:
|
||||
AppLocalizations.of(context).subscriptionDeleted(displayName),
|
||||
icon: Icons.delete_forever_rounded,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
@@ -484,7 +497,8 @@ class DetailScreenController extends ChangeNotifier {
|
||||
final locale = Localizations.localeOf(context).languageCode;
|
||||
|
||||
// 2. 해지 안내 URL 찾기
|
||||
String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl(
|
||||
String? cancellationUrl =
|
||||
await SubscriptionUrlMatcher.findCancellationUrl(
|
||||
serviceName: subscription.serviceName,
|
||||
websiteUrl: subscription.websiteUrl,
|
||||
locale: locale == 'ko' ? 'kr' : 'en',
|
||||
@@ -492,8 +506,10 @@ class DetailScreenController extends ChangeNotifier {
|
||||
|
||||
// 3. 해지 안내 URL이 없으면 구글 검색
|
||||
if (cancellationUrl == null) {
|
||||
final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
|
||||
cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
|
||||
final searchQuery =
|
||||
'${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
|
||||
cancellationUrl =
|
||||
'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
|
||||
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showInfo(
|
||||
@@ -515,6 +531,7 @@ class DetailScreenController extends ChangeNotifier {
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/sms_scanner.dart';
|
||||
import '../models/subscription.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../services/sms_scan/subscription_converter.dart';
|
||||
import '../services/sms_scan/subscription_filter.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -58,18 +58,20 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
// SMS 스캔 실행
|
||||
print('SMS 스캔 시작');
|
||||
final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions();
|
||||
print('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
||||
Log.i('SMS 스캔 시작');
|
||||
final scannedSubscriptionModels =
|
||||
await _smsScanner.scanForSubscriptions();
|
||||
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
||||
|
||||
if (scannedSubscriptionModels.isNotEmpty) {
|
||||
print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
||||
Log.d(
|
||||
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (scannedSubscriptionModels.isEmpty) {
|
||||
print('스캔된 구독이 없음');
|
||||
Log.i('스캔된 구독이 없음');
|
||||
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -77,18 +79,21 @@ class SmsScanController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// SubscriptionModel을 Subscription으로 변환
|
||||
final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels);
|
||||
final scannedSubscriptions =
|
||||
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
|
||||
|
||||
// 2회 이상 반복 결제된 구독만 필터링
|
||||
final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2);
|
||||
print('반복 결제된 구독: ${repeatSubscriptions.length}개');
|
||||
final repeatSubscriptions =
|
||||
_filter.filterByRepeatCount(scannedSubscriptions, 2);
|
||||
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}개');
|
||||
|
||||
if (repeatSubscriptions.isNotEmpty) {
|
||||
print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
|
||||
Log.d(
|
||||
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
|
||||
}
|
||||
|
||||
if (repeatSubscriptions.isEmpty) {
|
||||
print('반복 결제된 구독이 없음');
|
||||
Log.i('반복 결제된 구독이 없음');
|
||||
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -96,21 +101,24 @@ class SmsScanController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// 구독 목록 가져오기
|
||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
final provider =
|
||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
final existingSubscriptions = provider.subscriptions;
|
||||
print('기존 구독: ${existingSubscriptions.length}개');
|
||||
Log.d('기존 구독: ${existingSubscriptions.length}개');
|
||||
|
||||
// 중복 구독 필터링
|
||||
final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
||||
print('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
||||
final filteredSubscriptions =
|
||||
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
||||
Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
||||
|
||||
if (filteredSubscriptions.isNotEmpty) {
|
||||
print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
||||
Log.d(
|
||||
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
||||
}
|
||||
|
||||
// 중복 제거 후 신규 구독이 없는 경우
|
||||
if (filteredSubscriptions.isEmpty) {
|
||||
print('중복 제거 후 신규 구독이 없음');
|
||||
Log.i('중복 제거 후 신규 구독이 없음');
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
@@ -121,9 +129,10 @@ class SmsScanController extends ChangeNotifier {
|
||||
websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('SMS 스캔 중 오류 발생: $e');
|
||||
Log.e('SMS 스캔 중 오류 발생', e);
|
||||
if (context.mounted) {
|
||||
_errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
|
||||
_errorMessage =
|
||||
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -136,17 +145,22 @@ class SmsScanController extends ChangeNotifier {
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
|
||||
try {
|
||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final provider =
|
||||
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 처리
|
||||
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
|
||||
? websiteUrlController.text.trim()
|
||||
: subscription.websiteUrl;
|
||||
|
||||
print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
|
||||
Log.d(
|
||||
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
|
||||
|
||||
// addSubscription 호출
|
||||
await provider.addSubscription(
|
||||
@@ -162,19 +176,20 @@ class SmsScanController extends ChangeNotifier {
|
||||
currency: subscription.currency,
|
||||
);
|
||||
|
||||
print('구독 추가 성공: ${subscription.serviceName}');
|
||||
|
||||
Log.i('구독 추가 성공: ${subscription.serviceName}');
|
||||
if (!context.mounted) return;
|
||||
moveToNextSubscription(context);
|
||||
} catch (e) {
|
||||
print('구독 추가 중 오류 발생: $e');
|
||||
Log.e('구독 추가 중 오류 발생', e);
|
||||
// 오류가 있어도 다음 구독으로 이동
|
||||
if (!context.mounted) return;
|
||||
moveToNextSubscription(context);
|
||||
}
|
||||
}
|
||||
|
||||
void skipCurrentSubscription(BuildContext context) {
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
print('구독 건너뛰기: ${subscription.serviceName}');
|
||||
Log.i('구독 건너뛰기: ${subscription.serviceName}');
|
||||
moveToNextSubscription(context);
|
||||
}
|
||||
|
||||
@@ -193,7 +208,8 @@ class SmsScanController extends ChangeNotifier {
|
||||
|
||||
void navigateToHome(BuildContext context) {
|
||||
// NavigationProvider를 사용하여 홈 화면으로 이동
|
||||
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||
final navigationProvider =
|
||||
Provider.of<NavigationProvider>(context, listen: false);
|
||||
navigationProvider.updateCurrentIndex(0);
|
||||
}
|
||||
|
||||
@@ -209,7 +225,7 @@ class SmsScanController extends ChangeNotifier {
|
||||
(cat) => cat.name == 'other',
|
||||
orElse: () => categoryProvider.categories.first,
|
||||
);
|
||||
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
|
||||
Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
|
||||
return otherCategory.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,22 +14,21 @@ class AppLocalizations {
|
||||
|
||||
// JSON 파일에서 번역 데이터 로드
|
||||
Future<void> load() async {
|
||||
String jsonString =
|
||||
await rootBundle.loadString('assets/data/text.json');
|
||||
String jsonString = await rootBundle.loadString('assets/data/text.json');
|
||||
Map<String, dynamic> jsonMap = json.decode(jsonString);
|
||||
_localizedStrings = jsonMap[locale.languageCode];
|
||||
}
|
||||
|
||||
String get appTitle => _localizedStrings['appTitle'] ?? 'SubManager';
|
||||
String get appSubtitle => _localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily';
|
||||
String get appSubtitle =>
|
||||
_localizedStrings['appSubtitle'] ?? 'Manage subscriptions easily';
|
||||
String get subscriptionManagement =>
|
||||
_localizedStrings['subscriptionManagement'] ?? 'Subscription Management';
|
||||
String get addSubscription =>
|
||||
_localizedStrings['addSubscription'] ?? 'Add Subscription';
|
||||
String get subscriptionName =>
|
||||
_localizedStrings['subscriptionName'] ?? 'Service Name';
|
||||
String get monthlyCost =>
|
||||
_localizedStrings['monthlyCost'] ?? 'Monthly Cost';
|
||||
String get monthlyCost => _localizedStrings['monthlyCost'] ?? 'Monthly Cost';
|
||||
String get billingCycle =>
|
||||
_localizedStrings['billingCycle'] ?? 'Billing Cycle';
|
||||
String get nextBillingDate =>
|
||||
@@ -55,29 +54,50 @@ class AppLocalizations {
|
||||
_localizedStrings['categoryManagement'] ?? 'Category Management';
|
||||
String get categoryName =>
|
||||
_localizedStrings['categoryName'] ?? 'Category Name';
|
||||
String get selectColor =>
|
||||
_localizedStrings['selectColor'] ?? 'Select Color';
|
||||
String get selectIcon =>
|
||||
_localizedStrings['selectIcon'] ?? 'Select Icon';
|
||||
String get addCategory =>
|
||||
_localizedStrings['addCategory'] ?? 'Add Category';
|
||||
String get selectColor => _localizedStrings['selectColor'] ?? 'Select Color';
|
||||
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
|
||||
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
|
||||
String get settings => _localizedStrings['settings'] ?? 'Settings';
|
||||
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
|
||||
String get language => _localizedStrings['language'] ?? 'Language';
|
||||
String get notifications =>
|
||||
_localizedStrings['notifications'] ?? 'Notifications';
|
||||
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
|
||||
// SMS 권한 온보딩/설정
|
||||
String get smsPermissionTitle =>
|
||||
_localizedStrings['smsPermissionTitle'] ?? 'Request SMS Permission';
|
||||
String get smsPermissionReasonTitle =>
|
||||
_localizedStrings['smsPermissionReasonTitle'] ?? 'Why';
|
||||
String get smsPermissionReasonBody =>
|
||||
_localizedStrings['smsPermissionReasonBody'] ??
|
||||
'We analyze payment-related SMS to auto-detect subscriptions. Processing happens locally only.';
|
||||
String get smsPermissionScopeTitle =>
|
||||
_localizedStrings['smsPermissionScopeTitle'] ?? 'Scope';
|
||||
String get smsPermissionScopeBody =>
|
||||
_localizedStrings['smsPermissionScopeBody'] ??
|
||||
'We scan only payment-related SMS patterns (service/amount/date) locally; no data leaves your device.';
|
||||
String get permanentlyDeniedMessage =>
|
||||
_localizedStrings['permanentlyDeniedMessage'] ??
|
||||
'Permission is permanently denied. Enable it in Settings.';
|
||||
String get openSettings =>
|
||||
_localizedStrings['openSettings'] ?? 'Open Settings';
|
||||
String get later => _localizedStrings['later'] ?? 'Later';
|
||||
String get requesting => _localizedStrings['requesting'] ?? 'Requesting...';
|
||||
String get smsPermissionLabel =>
|
||||
_localizedStrings['smsPermissionLabel'] ?? 'SMS Permission';
|
||||
// 알림 설정
|
||||
String get notificationPermission =>
|
||||
_localizedStrings['notificationPermission'] ?? 'Notification Permission';
|
||||
String get notificationPermissionDesc =>
|
||||
_localizedStrings['notificationPermissionDesc'] ?? 'Permission is required to receive notifications';
|
||||
_localizedStrings['notificationPermissionDesc'] ??
|
||||
'Permission is required to receive notifications';
|
||||
String get requestPermission =>
|
||||
_localizedStrings['requestPermission'] ?? 'Request Permission';
|
||||
String get paymentNotification =>
|
||||
_localizedStrings['paymentNotification'] ?? 'Payment Due Notification';
|
||||
String get paymentNotificationDesc =>
|
||||
_localizedStrings['paymentNotificationDesc'] ?? 'Receive notification on payment due date';
|
||||
_localizedStrings['paymentNotificationDesc'] ??
|
||||
'Receive notification on payment due date';
|
||||
String get notificationTiming =>
|
||||
_localizedStrings['notificationTiming'] ?? 'Notification Timing';
|
||||
String get notificationTime =>
|
||||
@@ -85,11 +105,14 @@ class AppLocalizations {
|
||||
String get dailyReminder =>
|
||||
_localizedStrings['dailyReminder'] ?? 'Daily Reminder';
|
||||
String get dailyReminderEnabled =>
|
||||
_localizedStrings['dailyReminderEnabled'] ?? 'Receive daily notifications until payment date';
|
||||
_localizedStrings['dailyReminderEnabled'] ??
|
||||
'Receive daily notifications until payment date';
|
||||
String get dailyReminderDisabled =>
|
||||
_localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
|
||||
_localizedStrings['dailyReminderDisabled'] ??
|
||||
'Receive notification @ day(s) before payment';
|
||||
String get notificationPermissionDenied =>
|
||||
_localizedStrings['notificationPermissionDenied'] ?? 'Notification permission denied';
|
||||
_localizedStrings['notificationPermissionDenied'] ??
|
||||
'Notification permission denied';
|
||||
// 앱 정보
|
||||
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
|
||||
String get version => _localizedStrings['version'] ?? 'Version';
|
||||
@@ -102,7 +125,8 @@ class AppLocalizations {
|
||||
String get lightTheme => _localizedStrings['lightTheme'] ?? 'Light';
|
||||
String get darkTheme => _localizedStrings['darkTheme'] ?? 'Dark';
|
||||
String get oledTheme => _localizedStrings['oledTheme'] ?? 'OLED Black';
|
||||
String get systemTheme => _localizedStrings['systemTheme'] ?? 'System Default';
|
||||
String get systemTheme =>
|
||||
_localizedStrings['systemTheme'] ?? 'System Default';
|
||||
// 기타 메시지
|
||||
String get subscriptionAdded =>
|
||||
_localizedStrings['subscriptionAdded'] ?? 'Subscription added';
|
||||
@@ -112,72 +136,133 @@ class AppLocalizations {
|
||||
String get japanese => _localizedStrings['japanese'] ?? '日本語';
|
||||
String get chinese => _localizedStrings['chinese'] ?? '中文';
|
||||
// 날짜
|
||||
String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before';
|
||||
String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before';
|
||||
String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days before';
|
||||
String get oneDayBefore =>
|
||||
_localizedStrings['oneDayBefore'] ?? '1 day before';
|
||||
String get twoDaysBefore =>
|
||||
_localizedStrings['twoDaysBefore'] ?? '2 days before';
|
||||
String get threeDaysBefore =>
|
||||
_localizedStrings['threeDaysBefore'] ?? '3 days before';
|
||||
// 추가 메시지
|
||||
String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields';
|
||||
String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated';
|
||||
String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.';
|
||||
String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
|
||||
String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.';
|
||||
String get requiredFieldsError =>
|
||||
_localizedStrings['requiredFieldsError'] ??
|
||||
'Please fill in all required fields';
|
||||
String get subscriptionUpdated =>
|
||||
_localizedStrings['subscriptionUpdated'] ??
|
||||
'Subscription information has been updated';
|
||||
String get officialCancelPageNotFound =>
|
||||
_localizedStrings['officialCancelPageNotFound'] ??
|
||||
'Official cancellation page not found. Redirecting to Google search.';
|
||||
String get cannotOpenWebsite =>
|
||||
_localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
|
||||
String get noWebsiteInfo =>
|
||||
_localizedStrings['noWebsiteInfo'] ??
|
||||
'No website information available. Please cancel through the website.';
|
||||
String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode';
|
||||
String get changesAppliedAfterSave => _localizedStrings['changesAppliedAfterSave'] ?? 'Changes will be applied after saving';
|
||||
String get changesAppliedAfterSave =>
|
||||
_localizedStrings['changesAppliedAfterSave'] ??
|
||||
'Changes will be applied after saving';
|
||||
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
|
||||
String get monthlyExpense => _localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
|
||||
String get monthlyExpense =>
|
||||
_localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
|
||||
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
|
||||
String get websiteUrlOptional => _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
|
||||
String get websiteUrlOptional =>
|
||||
_localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
|
||||
String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
|
||||
String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
|
||||
String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
|
||||
String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
|
||||
String get eventPriceHint =>
|
||||
_localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
|
||||
String get eventPriceRequired =>
|
||||
_localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
|
||||
String get invalidPrice =>
|
||||
_localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
|
||||
String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
|
||||
String get home => _localizedStrings['home'] ?? 'Home';
|
||||
String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
|
||||
String get back => _localizedStrings['back'] ?? 'Back';
|
||||
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App';
|
||||
String get exitAppConfirm => _localizedStrings['exitAppConfirm'] ?? 'Are you sure you want to exit SubManager?';
|
||||
String get exitAppConfirm =>
|
||||
_localizedStrings['exitAppConfirm'] ??
|
||||
'Are you sure you want to exit SubManager?';
|
||||
String get exit => _localizedStrings['exit'] ?? 'Exit';
|
||||
String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found';
|
||||
String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
|
||||
String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com';
|
||||
String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication';
|
||||
String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication';
|
||||
String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.';
|
||||
String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
|
||||
String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found';
|
||||
String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
|
||||
String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving';
|
||||
String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found';
|
||||
String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
|
||||
String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.';
|
||||
String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)';
|
||||
String get pageNotFound =>
|
||||
_localizedStrings['pageNotFound'] ?? 'Page not found';
|
||||
String get serviceNameExample =>
|
||||
_localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
|
||||
String get urlExample =>
|
||||
_localizedStrings['urlExample'] ?? 'https://example.com';
|
||||
String get appLockDesc =>
|
||||
_localizedStrings['appLockDesc'] ??
|
||||
'App lock with biometric authentication';
|
||||
String get unlockWithBiometric =>
|
||||
_localizedStrings['unlockWithBiometric'] ??
|
||||
'Unlock with biometric authentication';
|
||||
String get authenticationFailed =>
|
||||
_localizedStrings['authenticationFailed'] ??
|
||||
'Authentication failed. Please try again.';
|
||||
String get smsPermissionRequired =>
|
||||
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
|
||||
String get noSubscriptionSmsFound =>
|
||||
_localizedStrings['noSubscriptionSmsFound'] ??
|
||||
'No subscription related SMS found';
|
||||
String get smsScanError =>
|
||||
_localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
|
||||
String get saveError =>
|
||||
_localizedStrings['saveError'] ?? 'Error occurred while saving';
|
||||
String get newSubscriptionSmsNotFound =>
|
||||
_localizedStrings['newSubscriptionSmsNotFound'] ??
|
||||
'No new subscription SMS found';
|
||||
String get subscriptionAddError =>
|
||||
_localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
|
||||
String get allSubscriptionsProcessed =>
|
||||
_localizedStrings['allSubscriptionsProcessed'] ??
|
||||
'All subscriptions have been processed.';
|
||||
String get websiteUrlExtracted =>
|
||||
_localizedStrings['websiteUrlExtracted'] ??
|
||||
'Website URL (Auto-extracted)';
|
||||
String get startDate => _localizedStrings['startDate'] ?? 'Start Date';
|
||||
String get endDate => _localizedStrings['endDate'] ?? 'End Date';
|
||||
|
||||
// 새로 추가된 항목들
|
||||
String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost';
|
||||
String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
|
||||
String get monthlyTotalSubscriptionCost =>
|
||||
_localizedStrings['monthlyTotalSubscriptionCost'] ??
|
||||
'Total Monthly Subscription Cost';
|
||||
String get todaysExchangeRate =>
|
||||
_localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
|
||||
String get won => _localizedStrings['won'] ?? 'KRW';
|
||||
String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
|
||||
String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services';
|
||||
String get estimatedAnnualCost =>
|
||||
_localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
|
||||
String get totalSubscriptionServices =>
|
||||
_localizedStrings['totalSubscriptionServices'] ??
|
||||
'Total Subscription Services';
|
||||
String get services => _localizedStrings['services'] ?? 'services';
|
||||
String get eventDiscountActive => _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
|
||||
String get eventDiscountActive =>
|
||||
_localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
|
||||
String get saving => _localizedStrings['saving'] ?? 'Saving';
|
||||
String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
|
||||
String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
|
||||
String get paymentDueToday =>
|
||||
_localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
|
||||
String get paymentInfoNeeded =>
|
||||
_localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
|
||||
String get event => _localizedStrings['event'] ?? 'Event';
|
||||
|
||||
// 카테고리 getter들
|
||||
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
|
||||
String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
|
||||
String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
|
||||
String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV';
|
||||
String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
|
||||
String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
|
||||
String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming';
|
||||
String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office';
|
||||
String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service';
|
||||
String get categoryOttVideo =>
|
||||
_localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
|
||||
String get categoryStorageCloud =>
|
||||
_localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
|
||||
String get categoryTelecomInternetTv =>
|
||||
_localizedStrings['categoryTelecomInternetTv'] ??
|
||||
'Telecom · Internet · TV';
|
||||
String get categoryLifestyle =>
|
||||
_localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
|
||||
String get categoryShoppingEcommerce =>
|
||||
_localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
|
||||
String get categoryProgramming =>
|
||||
_localizedStrings['categoryProgramming'] ?? 'Programming';
|
||||
String get categoryCollaborationOffice =>
|
||||
_localizedStrings['categoryCollaborationOffice'] ??
|
||||
'Collaboration/Office';
|
||||
String get categoryAiService =>
|
||||
_localizedStrings['categoryAiService'] ?? 'AI Service';
|
||||
String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
|
||||
|
||||
// 동적 메시지 생성 메서드
|
||||
@@ -186,115 +271,166 @@ class AppLocalizations {
|
||||
}
|
||||
|
||||
String dailyReminderDisabledWithDays(int days) {
|
||||
final template = _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
|
||||
final template = _localizedStrings['dailyReminderDisabled'] ??
|
||||
'Receive notification @ day(s) before payment';
|
||||
return template.replaceAll('@', days.toString());
|
||||
}
|
||||
|
||||
String subscriptionAddedWithName(String serviceName) {
|
||||
final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
|
||||
final template =
|
||||
_localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
|
||||
return template.replaceAll('@', serviceName);
|
||||
}
|
||||
|
||||
String subscriptionDeleted(String serviceName) {
|
||||
final template = _localizedStrings['subscriptionDeleted'] ?? '@ subscription has been deleted';
|
||||
final template = _localizedStrings['subscriptionDeleted'] ??
|
||||
'@ subscription has been deleted';
|
||||
return template.replaceAll('@', serviceName);
|
||||
}
|
||||
|
||||
String totalExpenseCopied(String amount) {
|
||||
final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
|
||||
final template =
|
||||
_localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
|
||||
return template.replaceAll('@', amount);
|
||||
}
|
||||
|
||||
String serviceRecognized(String serviceName) {
|
||||
final template = _localizedStrings['serviceRecognized'] ?? '@ service has been recognized automatically.';
|
||||
final template = _localizedStrings['serviceRecognized'] ??
|
||||
'@ service has been recognized automatically.';
|
||||
return template.replaceAll('@', serviceName);
|
||||
}
|
||||
|
||||
String smsScanErrorWithMessage(String error) {
|
||||
final template = _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan: @';
|
||||
final template = _localizedStrings['smsScanError'] ??
|
||||
'Error occurred during SMS scan: @';
|
||||
return template.replaceAll('@', error);
|
||||
}
|
||||
|
||||
String saveErrorWithMessage(String error) {
|
||||
final template = _localizedStrings['saveError'] ?? 'Error occurred while saving: @';
|
||||
final template =
|
||||
_localizedStrings['saveError'] ?? 'Error occurred while saving: @';
|
||||
return template.replaceAll('@', error);
|
||||
}
|
||||
|
||||
String subscriptionAddErrorWithMessage(String error) {
|
||||
final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @';
|
||||
final template = _localizedStrings['subscriptionAddError'] ??
|
||||
'Error adding subscription: @';
|
||||
return template.replaceAll('@', error);
|
||||
}
|
||||
|
||||
String subscriptionSkipped(String serviceName) {
|
||||
final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
|
||||
final template =
|
||||
_localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
|
||||
return template.replaceAll('@', serviceName);
|
||||
}
|
||||
|
||||
// 홈화면 관련
|
||||
String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
|
||||
String get mySubscriptions =>
|
||||
_localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
|
||||
|
||||
String subscriptionCount(int count) {
|
||||
if (locale.languageCode == 'ko') {
|
||||
return '${count}개';
|
||||
return '$count개';
|
||||
} else if (locale.languageCode == 'ja') {
|
||||
return '${count}個';
|
||||
return '$count個';
|
||||
} else if (locale.languageCode == 'zh') {
|
||||
return '${count}个';
|
||||
return '$count个';
|
||||
} else {
|
||||
return count.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// 분석화면 관련
|
||||
String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
|
||||
String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
|
||||
String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense';
|
||||
String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio';
|
||||
String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
|
||||
String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
|
||||
String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
|
||||
String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
|
||||
String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense';
|
||||
String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services';
|
||||
String get monthlyExpenseTitle =>
|
||||
_localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
|
||||
String get recentSixMonthsTrend =>
|
||||
_localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
|
||||
String get monthlySubscriptionExpense =>
|
||||
_localizedStrings['monthlySubscriptionExpense'] ??
|
||||
'Monthly subscription expense';
|
||||
String get subscriptionServiceRatio =>
|
||||
_localizedStrings['subscriptionServiceRatio'] ??
|
||||
'Subscription Service Ratio';
|
||||
String get monthlyExpenseBasis =>
|
||||
_localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
|
||||
String get noSubscriptionServices =>
|
||||
_localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
|
||||
String get totalExpenseSummary =>
|
||||
_localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
|
||||
String get monthlyTotalAmount =>
|
||||
_localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
|
||||
String get totalExpense =>
|
||||
_localizedStrings['totalExpense'] ?? 'Total Expense';
|
||||
String get totalServices =>
|
||||
_localizedStrings['totalServices'] ?? 'Total Services';
|
||||
String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
|
||||
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
|
||||
String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
|
||||
String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress';
|
||||
String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
|
||||
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress';
|
||||
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount';
|
||||
String get eventDiscountStatus =>
|
||||
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
|
||||
String get inProgressUnit =>
|
||||
_localizedStrings['inProgressUnit'] ?? 'in progress';
|
||||
String get monthlySavingAmount =>
|
||||
_localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
|
||||
String get eventsInProgress =>
|
||||
_localizedStrings['eventsInProgress'] ?? 'Events in Progress';
|
||||
String get discountPercent =>
|
||||
_localizedStrings['discountPercent'] ?? '% discount';
|
||||
String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
|
||||
|
||||
// SMS 스캔 관련
|
||||
String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
|
||||
String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services';
|
||||
String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.';
|
||||
String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.';
|
||||
String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found';
|
||||
String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times';
|
||||
String get scanTextMessages => _localizedStrings['scanTextMessages'] ?? 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
|
||||
String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning';
|
||||
String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription';
|
||||
String get scanningMessages =>
|
||||
_localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
|
||||
String get findingSubscriptions =>
|
||||
_localizedStrings['findingSubscriptions'] ??
|
||||
'Finding subscription services';
|
||||
String get subscriptionNotFound =>
|
||||
_localizedStrings['subscriptionNotFound'] ??
|
||||
'Subscription information not found.';
|
||||
String get repeatSubscriptionNotFound =>
|
||||
_localizedStrings['repeatSubscriptionNotFound'] ??
|
||||
'No repeated subscription information found.';
|
||||
String get newSubscriptionNotFound =>
|
||||
_localizedStrings['newSubscriptionNotFound'] ??
|
||||
'No new subscription SMS found';
|
||||
String get findRepeatSubscriptions =>
|
||||
_localizedStrings['findRepeatSubscriptions'] ??
|
||||
'Find subscriptions paid 2+ times';
|
||||
String get scanTextMessages =>
|
||||
_localizedStrings['scanTextMessages'] ??
|
||||
'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
|
||||
String get startScanning =>
|
||||
_localizedStrings['startScanning'] ?? 'Start Scanning';
|
||||
String get foundSubscription =>
|
||||
_localizedStrings['foundSubscription'] ?? 'Found subscription';
|
||||
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
|
||||
String get nextBillingDateLabel => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
|
||||
String get nextBillingDateLabel =>
|
||||
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
|
||||
String get category => _localizedStrings['category'] ?? 'Category';
|
||||
String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
|
||||
String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
|
||||
String get websiteUrlAuto =>
|
||||
_localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
|
||||
String get websiteUrlHint =>
|
||||
_localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
|
||||
String get skip => _localizedStrings['skip'] ?? 'Skip';
|
||||
String get add => _localizedStrings['add'] ?? 'Add';
|
||||
String get nextBillingDateRequired => _localizedStrings['nextBillingDateRequired'] ?? 'Next billing date verification required';
|
||||
String get nextBillingDateRequired =>
|
||||
_localizedStrings['nextBillingDateRequired'] ??
|
||||
'Next billing date verification required';
|
||||
|
||||
String nextBillingDateEstimated(String date, int days) {
|
||||
final template = _localizedStrings['nextBillingDateEstimated'] ?? 'Next estimated billing date: @ (# days later)';
|
||||
final template = _localizedStrings['nextBillingDateEstimated'] ??
|
||||
'Next estimated billing date: @ (# days later)';
|
||||
return template.replaceAll('@', date).replaceAll('#', days.toString());
|
||||
}
|
||||
|
||||
String nextBillingDateInfo(String date, int days) {
|
||||
final template = _localizedStrings['nextBillingDateInfo'] ?? 'Next billing date: @ (# days later)';
|
||||
final template = _localizedStrings['nextBillingDateInfo'] ??
|
||||
'Next billing date: @ (# days later)';
|
||||
return template.replaceAll('@', date).replaceAll('#', days.toString());
|
||||
}
|
||||
|
||||
String get nextBillingDatePastRequired => _localizedStrings['nextBillingDatePastRequired'] ?? 'Next billing date verification required (past date)';
|
||||
String get nextBillingDatePastRequired =>
|
||||
_localizedStrings['nextBillingDatePastRequired'] ??
|
||||
'Next billing date verification required (past date)';
|
||||
|
||||
String formatDate(DateTime date) {
|
||||
if (locale.languageCode == 'ko') {
|
||||
@@ -304,23 +440,37 @@ class AppLocalizations {
|
||||
} else if (locale.languageCode == 'zh') {
|
||||
return '${date.year}年${date.month}月${date.day}日';
|
||||
} else {
|
||||
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
final months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
String repeatCountDetected(int count) {
|
||||
final template = _localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
|
||||
final template =
|
||||
_localizedStrings['repeatCountDetected'] ?? '@ payment(s) detected';
|
||||
return template.replaceAll('@', count.toString());
|
||||
}
|
||||
|
||||
String servicesInProgress(int count) {
|
||||
if (locale.languageCode == 'ko') {
|
||||
return '${count}개 진행중';
|
||||
return '$count개 진행중';
|
||||
} else if (locale.languageCode == 'ja') {
|
||||
return '${count}個進行中';
|
||||
return '$count個進行中';
|
||||
} else if (locale.languageCode == 'zh') {
|
||||
return '${count}个进行中';
|
||||
return '$count个进行中';
|
||||
} else {
|
||||
return '$count in progress';
|
||||
}
|
||||
@@ -328,7 +478,8 @@ class AppLocalizations {
|
||||
|
||||
// 새로 추가된 동적 메서드들
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -338,27 +489,37 @@ class AppLocalizations {
|
||||
}
|
||||
|
||||
String exchangeRateFormat(String rate) {
|
||||
final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
|
||||
final template =
|
||||
_localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
|
||||
return template.replaceAll('@', rate);
|
||||
}
|
||||
|
||||
// 결제 주기 결제 메시지
|
||||
String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment';
|
||||
String get billingCyclePayment =>
|
||||
_localizedStrings['billingCyclePayment'] ?? '@ Payment';
|
||||
|
||||
// 할인 금액 표시 getter들
|
||||
String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@';
|
||||
String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@';
|
||||
String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@';
|
||||
String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
|
||||
String get discountAmountWon =>
|
||||
_localizedStrings['discountAmountWon'] ?? 'Save ₩@';
|
||||
String get discountAmountDollar =>
|
||||
_localizedStrings['discountAmountDollar'] ?? 'Save \$@';
|
||||
String get discountAmountYen =>
|
||||
_localizedStrings['discountAmountYen'] ?? 'Save ¥@';
|
||||
String get discountAmountYuan =>
|
||||
_localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
|
||||
|
||||
// 결제 주기 관련 getter
|
||||
String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
|
||||
String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
|
||||
String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
|
||||
String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly';
|
||||
String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
|
||||
String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
|
||||
String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly';
|
||||
String get billingCycleMonthly =>
|
||||
_localizedStrings['billingCycleMonthly'] ?? 'Monthly';
|
||||
String get billingCycleQuarterly =>
|
||||
_localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
|
||||
String get billingCycleHalfYearly =>
|
||||
_localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
|
||||
String get billingCycleYearly =>
|
||||
_localizedStrings['billingCycleYearly'] ?? 'Yearly';
|
||||
|
||||
// 색상 관련 getter
|
||||
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
|
||||
@@ -368,48 +529,75 @@ class AppLocalizations {
|
||||
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
|
||||
|
||||
// 날짜 형식 관련 getter
|
||||
String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
|
||||
String get dateFormatFull =>
|
||||
_localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
|
||||
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
|
||||
|
||||
// USD 환율 표시 형식
|
||||
String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
|
||||
String get exchangeRateDisplay =>
|
||||
_localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
|
||||
|
||||
// 라벨 및 힌트 텍스트
|
||||
String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name';
|
||||
String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
|
||||
String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
|
||||
String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
|
||||
String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
|
||||
String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
|
||||
String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price';
|
||||
String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
|
||||
String get labelServiceName =>
|
||||
_localizedStrings['labelServiceName'] ?? 'Service Name';
|
||||
String get hintServiceName =>
|
||||
_localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
|
||||
String get labelMonthlyExpense =>
|
||||
_localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
|
||||
String get labelNextBillingDate =>
|
||||
_localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
|
||||
String get labelWebsiteUrl =>
|
||||
_localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
|
||||
String get hintWebsiteUrl =>
|
||||
_localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
|
||||
String get labelEventPrice =>
|
||||
_localizedStrings['labelEventPrice'] ?? 'Event Price';
|
||||
String get hintEventPrice =>
|
||||
_localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
|
||||
String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
|
||||
|
||||
// 기타 번역
|
||||
String get subscription => _localizedStrings['subscription'] ?? 'Subscription';
|
||||
String get subscription =>
|
||||
_localizedStrings['subscription'] ?? 'Subscription';
|
||||
String get movie => _localizedStrings['movie'] ?? 'Movie';
|
||||
String get music => _localizedStrings['music'] ?? 'Music';
|
||||
String get exercise => _localizedStrings['exercise'] ?? 'Exercise';
|
||||
String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
|
||||
String get currency => _localizedStrings['currency'] ?? 'Currency';
|
||||
String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information';
|
||||
String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
|
||||
String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.';
|
||||
String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
|
||||
String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name';
|
||||
String get websiteInfo =>
|
||||
_localizedStrings['websiteInfo'] ?? 'Website Information';
|
||||
String get cancelGuide =>
|
||||
_localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
|
||||
String get cancelServiceGuide =>
|
||||
_localizedStrings['cancelServiceGuide'] ??
|
||||
'To cancel this service, please go to the cancellation page through the link below.';
|
||||
String get goToCancelPage =>
|
||||
_localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
|
||||
String get urlAutoMatchInfo =>
|
||||
_localizedStrings['urlAutoMatchInfo'] ??
|
||||
'If URL is empty, it will be automatically matched based on the service name';
|
||||
String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
|
||||
|
||||
// 새로 추가된 getter들
|
||||
String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information';
|
||||
String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
|
||||
String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information';
|
||||
String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
|
||||
String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
|
||||
String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount';
|
||||
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
|
||||
String get serviceInfo =>
|
||||
_localizedStrings['serviceInfo'] ?? 'Service Information';
|
||||
String get newSubscriptionAdd =>
|
||||
_localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
|
||||
String get enterServiceInfo =>
|
||||
_localizedStrings['enterServiceInfo'] ?? 'Enter service information';
|
||||
String get addSubscriptionButton =>
|
||||
_localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
|
||||
String get serviceNameRequired =>
|
||||
_localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
|
||||
String get amountRequired =>
|
||||
_localizedStrings['amountRequired'] ?? 'Please enter amount';
|
||||
String get subscriptionDetail =>
|
||||
_localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
|
||||
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
|
||||
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
|
||||
String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
|
||||
String get invalidAmount =>
|
||||
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
|
||||
String get featureComingSoon =>
|
||||
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
|
||||
|
||||
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
|
||||
String getBillingCycleName(String billingCycleKey) {
|
||||
@@ -467,7 +655,8 @@ class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
|
||||
const AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => ['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
|
||||
bool isSupported(Locale locale) =>
|
||||
['en', 'ko', 'ja', 'zh'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) async {
|
||||
|
||||
@@ -22,11 +22,12 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:async' show unawaited;
|
||||
import 'utils/memory_manager.dart';
|
||||
import 'utils/logger.dart';
|
||||
import 'utils/performance_optimizer.dart';
|
||||
import 'navigator_key.dart';
|
||||
|
||||
// AdMob 활성화 플래그 (개발 중 false, 프로덕션 시 true로 변경)
|
||||
const bool enableAdMob = false;
|
||||
const bool enableAdMob = true;
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -44,16 +45,23 @@ Future<void> main() async {
|
||||
try {
|
||||
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
||||
|
||||
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
|
||||
// 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
|
||||
// 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
|
||||
const bool clearCacheOnStartup = bool.fromEnvironment(
|
||||
'CLEAR_CACHE_ON_STARTUP',
|
||||
defaultValue: false,
|
||||
);
|
||||
if (clearCacheOnStartup) {
|
||||
await DefaultCacheManager().emptyCache();
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('이미지 캐시 관리 초기화 완료');
|
||||
Log.d('이미지 캐시 관리 초기화 완료');
|
||||
PerformanceOptimizer.checkConstOptimization();
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('캐시 초기화 오류: $e');
|
||||
Log.e('캐시 초기화 오류', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ class AppNavigationObserver extends NavigatorObserver {
|
||||
if (newRoute != null) {
|
||||
_updateNavigationState(newRoute);
|
||||
}
|
||||
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||
debugPrint(
|
||||
'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||
}
|
||||
|
||||
void _updateNavigationState(Route<dynamic> route) {
|
||||
@@ -52,7 +53,8 @@ class AppNavigationObserver extends NavigatorObserver {
|
||||
|
||||
try {
|
||||
final context = navigator!.context;
|
||||
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||
final navigationProvider =
|
||||
Provider.of<NavigationProvider>(context, listen: false);
|
||||
navigationProvider.updateByRoute(routeName);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to update navigation state: $e');
|
||||
@@ -69,7 +71,8 @@ class AppNavigationObserver extends NavigatorObserver {
|
||||
|
||||
try {
|
||||
final context = navigator!.context;
|
||||
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||
final navigationProvider =
|
||||
Provider.of<NavigationProvider>(context, listen: false);
|
||||
navigationProvider.pop();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to handle pop with provider: $e');
|
||||
|
||||
@@ -59,9 +59,17 @@ class CategoryProvider extends ChangeNotifier {
|
||||
{'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
|
||||
{'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
|
||||
{'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'},
|
||||
{'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'},
|
||||
{
|
||||
'name': 'shoppingEcommerce',
|
||||
'color': '#FF9800',
|
||||
'icon': 'shopping_cart'
|
||||
},
|
||||
{'name': 'programming', 'color': '#795548', 'icon': 'code'},
|
||||
{'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'},
|
||||
{
|
||||
'name': 'collaborationOffice',
|
||||
'color': '#607D8B',
|
||||
'icon': 'business_center'
|
||||
},
|
||||
{'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
|
||||
{'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
|
||||
];
|
||||
|
||||
@@ -270,7 +270,8 @@ class NotificationProvider extends ChangeNotifier {
|
||||
// 첫 권한 부여 시 기본 설정 초기화
|
||||
Future<void> initializeDefaultSettingsOnFirstPermission() async {
|
||||
try {
|
||||
final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey);
|
||||
final firstGranted =
|
||||
await _secureStorage.read(key: _firstPermissionGrantedKey);
|
||||
if (firstGranted != 'true') {
|
||||
// 첫 권한 부여 시 기본값 설정
|
||||
await setReminderDays(2); // 2일 전 알림
|
||||
@@ -278,7 +279,8 @@ class NotificationProvider extends ChangeNotifier {
|
||||
await setPaymentEnabled(true); // 결제 예정 알림 활성화
|
||||
|
||||
// 첫 권한 부여 플래그 저장
|
||||
await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true');
|
||||
await _secureStorage.write(
|
||||
key: _firstPermissionGrantedKey, value: 'true');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('기본 설정 초기화 중 오류 발생: $e');
|
||||
|
||||
@@ -28,10 +28,11 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
final price = subscription.currentPrice;
|
||||
if (subscription.currency == 'USD') {
|
||||
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
|
||||
'\$${price} × ₩$rate = ₩${price * rate}');
|
||||
'\$$price × ₩$rate = ₩${price * rate}');
|
||||
return sum + (price * rate);
|
||||
}
|
||||
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
|
||||
debugPrint(
|
||||
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
|
||||
return sum + price;
|
||||
},
|
||||
);
|
||||
@@ -191,7 +192,6 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> clearAllSubscriptions() async {
|
||||
_isLoading = true;
|
||||
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 &&
|
||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||
await NotificationService.scheduleNotification(
|
||||
@@ -238,7 +239,6 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
if (subscription.isEventActive &&
|
||||
subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isBefore(DateTime.now())) {
|
||||
|
||||
subscription.isEventActive = false;
|
||||
await _subscriptionBox.put(subscription.id, subscription);
|
||||
hasChanges = true;
|
||||
@@ -255,9 +255,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
if (_subscriptions.isEmpty) return 0.0;
|
||||
|
||||
// locale이 제공되지 않으면 현재 로케일 사용
|
||||
final targetCurrency = locale != null
|
||||
? CurrencyUtil.getDefaultCurrency(locale)
|
||||
: 'KRW'; // 기본값
|
||||
final targetCurrency =
|
||||
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
|
||||
|
||||
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
|
||||
double total = 0.0;
|
||||
@@ -265,7 +264,7 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
for (final subscription in _subscriptions) {
|
||||
final currentPrice = subscription.currentPrice;
|
||||
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
|
||||
'${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||
|
||||
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
||||
currentPrice,
|
||||
@@ -281,14 +280,14 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
|
||||
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({String? locale}) async {
|
||||
Future<List<Map<String, dynamic>>> getMonthlyExpenseData(
|
||||
{String? locale}) async {
|
||||
final now = DateTime.now();
|
||||
final List<Map<String, dynamic>> monthlyData = [];
|
||||
|
||||
// locale이 제공되지 않으면 현재 로케일 사용
|
||||
final targetCurrency = locale != null
|
||||
? CurrencyUtil.getDefaultCurrency(locale)
|
||||
: 'KRW'; // 기본값
|
||||
final targetCurrency =
|
||||
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
|
||||
|
||||
// 최근 6개월 데이터 생성
|
||||
for (int i = 5; i >= 0; i--) {
|
||||
@@ -296,10 +295,12 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
double monthTotal = 0.0;
|
||||
|
||||
// 현재 월인지 확인
|
||||
final isCurrentMonth = (month.year == now.year && month.month == now.month);
|
||||
final isCurrentMonth =
|
||||
(month.year == now.year && month.month == now.month);
|
||||
|
||||
if (isCurrentMonth) {
|
||||
debugPrint('[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
|
||||
debugPrint(
|
||||
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
|
||||
}
|
||||
|
||||
// 해당 월에 활성화된 구독 계산
|
||||
@@ -307,11 +308,13 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
if (isCurrentMonth) {
|
||||
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
|
||||
final cost = subscription.currentPrice;
|
||||
debugPrint('[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
|
||||
'${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||
debugPrint(
|
||||
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
|
||||
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
|
||||
|
||||
// 통화 변환
|
||||
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
||||
final converted =
|
||||
await ExchangeRateService().convertBetweenCurrencies(
|
||||
cost,
|
||||
subscription.currency,
|
||||
targetCurrency,
|
||||
@@ -325,7 +328,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
|
||||
);
|
||||
|
||||
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||
if (subscriptionStartDate
|
||||
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||
subscription.nextBillingDate.isAfter(month)) {
|
||||
// 해당 월의 비용 계산 (이벤트 가격 고려)
|
||||
double cost;
|
||||
@@ -334,7 +338,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
subscription.eventStartDate != null &&
|
||||
subscription.eventEndDate != null &&
|
||||
// 이벤트 기간과 해당 월이 겹치는지 확인
|
||||
subscription.eventStartDate!.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||
subscription.eventStartDate!
|
||||
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||
subscription.eventEndDate!.isAfter(month)) {
|
||||
cost = subscription.eventPrice ?? subscription.monthlyCost;
|
||||
} else {
|
||||
@@ -342,7 +347,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// 통화 변환
|
||||
final converted = await ExchangeRateService().convertBetweenCurrencies(
|
||||
final converted =
|
||||
await ExchangeRateService().convertBetweenCurrencies(
|
||||
cost,
|
||||
subscription.currency,
|
||||
targetCurrency,
|
||||
@@ -354,7 +360,8 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
if (isCurrentMonth) {
|
||||
debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
|
||||
debugPrint(
|
||||
'[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
|
||||
}
|
||||
|
||||
monthlyData.add({
|
||||
@@ -431,10 +438,12 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
serviceName.contains('티빙') ||
|
||||
serviceName.contains('디즈니') ||
|
||||
serviceName.contains('넷플릭스')) {
|
||||
categoryId = categories.firstWhere(
|
||||
categoryId = categories
|
||||
.firstWhere(
|
||||
(cat) => cat.name == 'OTT 서비스',
|
||||
orElse: () => categories.first,
|
||||
).id;
|
||||
)
|
||||
.id;
|
||||
}
|
||||
// 음악 서비스
|
||||
else if (serviceName.contains('spotify') ||
|
||||
@@ -443,30 +452,36 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
serviceName.contains('지니') ||
|
||||
serviceName.contains('플로') ||
|
||||
serviceName.contains('벡스')) {
|
||||
categoryId = categories.firstWhere(
|
||||
categoryId = categories
|
||||
.firstWhere(
|
||||
(cat) => cat.name == 'music',
|
||||
orElse: () => categories.first,
|
||||
).id;
|
||||
)
|
||||
.id;
|
||||
}
|
||||
// AI 서비스
|
||||
else if (serviceName.contains('chatgpt') ||
|
||||
serviceName.contains('claude') ||
|
||||
serviceName.contains('midjourney') ||
|
||||
serviceName.contains('copilot')) {
|
||||
categoryId = categories.firstWhere(
|
||||
categoryId = categories
|
||||
.firstWhere(
|
||||
(cat) => cat.name == 'aiService',
|
||||
orElse: () => categories.first,
|
||||
).id;
|
||||
)
|
||||
.id;
|
||||
}
|
||||
// 프로그래밍/개발
|
||||
else if (serviceName.contains('github') ||
|
||||
serviceName.contains('intellij') ||
|
||||
serviceName.contains('webstorm') ||
|
||||
serviceName.contains('jetbrains')) {
|
||||
categoryId = categories.firstWhere(
|
||||
categoryId = categories
|
||||
.firstWhere(
|
||||
(cat) => cat.name == 'programming',
|
||||
orElse: () => categories.first,
|
||||
).id;
|
||||
)
|
||||
.id;
|
||||
}
|
||||
// 오피스/협업 툴
|
||||
else if (serviceName.contains('notion') ||
|
||||
@@ -476,31 +491,34 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
serviceName.contains('figma') ||
|
||||
serviceName.contains('icloud') ||
|
||||
serviceName.contains('아이클라우드')) {
|
||||
categoryId = categories.firstWhere(
|
||||
categoryId = categories
|
||||
.firstWhere(
|
||||
(cat) => cat.name == 'collaborationOffice',
|
||||
orElse: () => categories.first,
|
||||
).id;
|
||||
)
|
||||
.id;
|
||||
}
|
||||
// 기타 서비스 (기본값)
|
||||
else {
|
||||
categoryId = categories.firstWhere(
|
||||
categoryId = categories
|
||||
.firstWhere(
|
||||
(cat) => cat.name == 'other',
|
||||
orElse: () => categories.first,
|
||||
).id;
|
||||
)
|
||||
.id;
|
||||
}
|
||||
|
||||
if (categoryId != null) {
|
||||
subscription.categoryId = categoryId;
|
||||
await subscription.save();
|
||||
migratedCount++;
|
||||
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name;
|
||||
final categoryName =
|
||||
categories.firstWhere((cat) => cat.id == categoryId).name;
|
||||
debugPrint('✅ ${subscription.serviceName} → $categoryName');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedCount > 0) {
|
||||
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료');
|
||||
debugPrint('❎ 총 $migratedCount개의 구독에 categoryId 할당 완료');
|
||||
await refreshSubscriptions();
|
||||
} else {
|
||||
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:submanager/screens/sms_scan_screen.dart';
|
||||
import 'package:submanager/screens/analysis_screen.dart';
|
||||
import 'package:submanager/screens/settings_screen.dart';
|
||||
import 'package:submanager/screens/splash_screen.dart';
|
||||
import 'package:submanager/screens/sms_permission_screen.dart';
|
||||
import 'package:submanager/models/subscription_model.dart';
|
||||
|
||||
class AppRoutes {
|
||||
@@ -16,6 +17,7 @@ class AppRoutes {
|
||||
static const String smsScanner = '/sms-scanner';
|
||||
static const String analysis = '/analysis';
|
||||
static const String settings = '/settings';
|
||||
static const String smsPermission = '/sms-permission';
|
||||
|
||||
static Map<String, WidgetBuilder> getRoutes() {
|
||||
return {
|
||||
@@ -25,6 +27,7 @@ class AppRoutes {
|
||||
smsScanner: (context) => const SmsScanScreen(),
|
||||
analysis: (context) => const AnalysisScreen(),
|
||||
settings: (context) => const SettingsScreen(),
|
||||
smsPermission: (context) => const SmsPermissionScreen(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,7 +45,8 @@ class AppRoutes {
|
||||
case subscriptionDetail:
|
||||
final subscription = routeSettings.arguments as SubscriptionModel?;
|
||||
if (subscription != null) {
|
||||
return _buildRoute(DetailScreen(subscription: subscription), routeSettings);
|
||||
return _buildRoute(
|
||||
DetailScreen(subscription: subscription), routeSettings);
|
||||
}
|
||||
return _errorRoute();
|
||||
|
||||
@@ -55,6 +59,9 @@ class AppRoutes {
|
||||
case settings:
|
||||
return _buildRoute(const SettingsScreen(), routeSettings);
|
||||
|
||||
case smsPermission:
|
||||
return _buildRoute(const SmsPermissionScreen(), routeSettings);
|
||||
|
||||
default:
|
||||
return _errorRoute();
|
||||
}
|
||||
@@ -77,15 +84,18 @@ class AppRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
static void navigateTo(BuildContext context, String routeName, {Object? arguments}) {
|
||||
static void navigateTo(BuildContext context, String routeName,
|
||||
{Object? arguments}) {
|
||||
Navigator.pushNamed(context, routeName, arguments: arguments);
|
||||
}
|
||||
|
||||
static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) {
|
||||
static void navigateAndReplace(BuildContext context, String routeName,
|
||||
{Object? arguments}) {
|
||||
Navigator.pushReplacementNamed(context, routeName, arguments: arguments);
|
||||
}
|
||||
|
||||
static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) {
|
||||
static void navigateAndRemoveUntil(BuildContext context, String routeName,
|
||||
{Object? arguments}) {
|
||||
Navigator.pushNamedAndRemoveUntil(
|
||||
context,
|
||||
routeName,
|
||||
|
||||
@@ -48,7 +48,9 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
|
||||
|
||||
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
|
||||
if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) {
|
||||
if (currentHash != _lastDataHash &&
|
||||
!_isLoading &&
|
||||
_lastDataHash.isNotEmpty) {
|
||||
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
|
||||
_loadData();
|
||||
}
|
||||
@@ -71,7 +73,8 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
|
||||
|
||||
for (final sub in subscriptions) {
|
||||
buffer.write('_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
|
||||
buffer.write(
|
||||
'_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
@@ -166,7 +169,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
|
||||
// 2. 총 지출 요약 카드
|
||||
TotalExpenseSummaryCard(
|
||||
key: ValueKey('total_expense_${_lastDataHash}'),
|
||||
key: ValueKey('total_expense_$_lastDataHash'),
|
||||
subscriptions: subscriptions,
|
||||
totalExpense: _totalExpense,
|
||||
animationController: _animationController,
|
||||
@@ -176,7 +179,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
||||
|
||||
// 3. 월별 지출 차트
|
||||
MonthlyExpenseChartCard(
|
||||
key: ValueKey('monthly_expense_${_lastDataHash}'),
|
||||
key: ValueKey('monthly_expense_$_lastDataHash'),
|
||||
monthlyData: _monthlyData,
|
||||
animationController: _animationController,
|
||||
),
|
||||
|
||||
@@ -13,13 +13,13 @@ class AppLockScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.lock_outline,
|
||||
size: 80,
|
||||
color: AppColors.navyGray,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
const Text(
|
||||
'앱이 잠겨 있습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
@@ -28,7 +28,7 @@ class AppLockScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
const Text(
|
||||
'생체 인증으로 잠금을 해제하세요',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
@@ -42,7 +42,7 @@ class AppLockScreen extends StatelessWidget {
|
||||
final success = await appLock.authenticate();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'인증에 실패했습니다. 다시 시도해주세요.',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -43,7 +43,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
title: const Text(
|
||||
'카테고리 관리',
|
||||
style: TextStyle(
|
||||
color: AppColors.pureWhite,
|
||||
@@ -66,7 +66,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '카테고리 이름',
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
@@ -82,7 +82,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedColor,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '색상 선택',
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
@@ -90,15 +90,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: '#1976D2', child: Text(AppLocalizations.of(context).colorBlue, style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: '#1976D2',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorBlue,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: '#4CAF50', child: Text(AppLocalizations.of(context).colorGreen, style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: '#4CAF50',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorGreen,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: '#FF9800', child: Text(AppLocalizations.of(context).colorOrange, style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: '#FF9800',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorOrange,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: '#F44336', child: Text(AppLocalizations.of(context).colorRed, style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: '#F44336',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorRed,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: '#9C27B0', child: Text(AppLocalizations.of(context).colorPurple, style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: '#9C27B0',
|
||||
child: Text(
|
||||
AppLocalizations.of(context).colorPurple,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy))),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@@ -109,22 +129,38 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedIcon,
|
||||
decoration: InputDecoration(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '아이콘 선택',
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: 'subscriptions',
|
||||
child: Text('구독',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: 'movie',
|
||||
child: Text('영화',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: 'music_note',
|
||||
child: Text('음악',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: 'shopping_cart', child: Text('쇼핑', style: TextStyle(color: AppColors.darkNavy))),
|
||||
value: 'fitness_center',
|
||||
child: Text('운동',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
DropdownMenuItem(
|
||||
value: 'shopping_cart',
|
||||
child: Text('쇼핑',
|
||||
style:
|
||||
TextStyle(color: AppColors.darkNavy))),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@@ -135,7 +171,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _addCategory,
|
||||
child: Text(
|
||||
child: const Text(
|
||||
'카테고리 추가',
|
||||
style: TextStyle(
|
||||
color: AppColors.pureWhite,
|
||||
@@ -163,8 +199,9 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
int.parse(category.color.replaceAll('#', '0xFF'))),
|
||||
),
|
||||
title: Text(
|
||||
provider.getLocalizedCategoryName(context, category.name),
|
||||
style: TextStyle(
|
||||
provider.getLocalizedCategoryName(
|
||||
context, category.name),
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,7 +43,6 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseColor = _controller.getCardColor();
|
||||
@@ -110,8 +109,9 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
AppLocalizations.of(context).changesAppliedAfterSave,
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(context)
|
||||
.changesAppliedAfterSave,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
|
||||
@@ -236,7 +236,11 @@ class _MainScreenState extends State<MainScreen>
|
||||
body: IndexedStack(
|
||||
index: PlatformHelper.isIOS
|
||||
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
|
||||
: (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직
|
||||
: (currentIndex == 3
|
||||
? 3
|
||||
: currentIndex == 4
|
||||
? 4
|
||||
: currentIndex), // Android: 기존 로직
|
||||
children: _screens,
|
||||
),
|
||||
backgroundGradient: backgroundGradient,
|
||||
|
||||
@@ -5,13 +5,14 @@ import '../providers/notification_provider.dart';
|
||||
import 'dart:io';
|
||||
import '../services/notification_service.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../theme/adaptive_theme.dart';
|
||||
import '../widgets/glassmorphism_card.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/native_ad_widget.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/locale_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
import '../services/sms_service.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -228,6 +229,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
if (granted) {
|
||||
await provider.setEnabled(true);
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context)
|
||||
@@ -271,7 +273,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
elevation: 0,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -412,7 +414,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
@@ -476,6 +478,89 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// SMS 권한 설정
|
||||
if (!kIsWeb && Platform.isAndroid)
|
||||
GlassmorphismCard(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: FutureBuilder<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(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
|
||||
151
lib/screens/sms_permission_screen.dart
Normal file
151
lib/screens/sms_permission_screen.dart
Normal 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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../controllers/sms_scan_controller.dart';
|
||||
import '../widgets/sms_scan/scan_loading_widget.dart';
|
||||
import '../widgets/sms_scan/scan_initial_widget.dart';
|
||||
@@ -75,7 +74,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex];
|
||||
final currentSubscription =
|
||||
_controller.scannedSubscriptions[_controller.currentIndex];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../services/sms_service.dart';
|
||||
import '../utils/platform_helper.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
@@ -63,7 +66,8 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
));
|
||||
|
||||
// 랜덤 파티클 생성
|
||||
_generateParticles();
|
||||
// 접근성(모션 축소) 고려한 파티클 생성
|
||||
_generateParticles(reduced: ReduceMotion.platform());
|
||||
|
||||
_animationController.forward();
|
||||
|
||||
@@ -73,15 +77,17 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _generateParticles() {
|
||||
void _generateParticles({bool reduced = false}) {
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
final total = reduced ? 6 : 20;
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
for (int i = 0; i < total; i++) {
|
||||
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
|
||||
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
|
||||
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
|
||||
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
|
||||
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간
|
||||
final duration = (random % 10) / 10 * (reduced ? 1800 : 3000) +
|
||||
(reduced ? 1200 : 2000); // 축소 시 더 짧게
|
||||
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
||||
|
||||
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
||||
@@ -98,9 +104,20 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToNextScreen() {
|
||||
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
|
||||
// 모든 이전 라우트를 제거하고 홈으로 이동
|
||||
Future<void> navigateToNextScreen() async {
|
||||
// Android에서 SMS 권한이 없으면 권한 안내 화면으로 이동
|
||||
if (PlatformHelper.isAndroid) {
|
||||
final hasPermission = await SMSService.hasSMSPermission();
|
||||
if (!hasPermission && mounted) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
AppRoutes.smsPermission,
|
||||
(route) => false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
AppRoutes.main,
|
||||
(route) => false,
|
||||
@@ -244,7 +261,14 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
BorderRadius.circular(30),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 20, sigmaY: 20),
|
||||
sigmaX: ReduceMotion.scale(
|
||||
context,
|
||||
normal: 20,
|
||||
reduced: 8),
|
||||
sigmaY: ReduceMotion.scale(
|
||||
context,
|
||||
normal: 20,
|
||||
reduced: 8)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -269,7 +293,11 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
color:
|
||||
AppColors.shadowBlack,
|
||||
spreadRadius: 0,
|
||||
blurRadius: 30,
|
||||
blurRadius:
|
||||
ReduceMotion.scale(
|
||||
context,
|
||||
normal: 30,
|
||||
reduced: 12),
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
@@ -385,7 +413,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: CircularProgressIndicator(
|
||||
child: const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.pureWhite),
|
||||
strokeWidth: 3,
|
||||
|
||||
97
lib/services/cache_manager.dart
Normal file
97
lib/services/cache_manager.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
class _CacheEntry<T> {
|
||||
final T value;
|
||||
final DateTime expiresAt;
|
||||
final int size;
|
||||
|
||||
_CacheEntry(
|
||||
{required this.value, required this.expiresAt, required this.size});
|
||||
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
/// 간단한 메모리 기반 캐시 매니저 (TTL/최대 개수/용량 제한)
|
||||
class SimpleCacheManager<T> {
|
||||
final int maxEntries;
|
||||
final int maxBytes;
|
||||
final Duration ttl;
|
||||
|
||||
final Map<String, _CacheEntry<T>> _store = <String, _CacheEntry<T>>{};
|
||||
int _currentBytes = 0;
|
||||
|
||||
// 간단한 메트릭
|
||||
int _hits = 0;
|
||||
int _misses = 0;
|
||||
int _puts = 0;
|
||||
int _evictions = 0;
|
||||
|
||||
SimpleCacheManager({
|
||||
this.maxEntries = 128,
|
||||
this.maxBytes = 1024 * 1024, // 1MB
|
||||
this.ttl = const Duration(minutes: 30),
|
||||
});
|
||||
|
||||
T? get(String key) {
|
||||
final entry = _store.remove(key);
|
||||
if (entry == null) return null;
|
||||
if (entry.isExpired) {
|
||||
_currentBytes -= entry.size;
|
||||
_misses++;
|
||||
return null;
|
||||
}
|
||||
// LRU 갱신: 재삽입으로 가장 최근으로 이동
|
||||
_store[key] = entry;
|
||||
_hits++;
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
void set(String key, T value, {int size = 1, Duration? customTtl}) {
|
||||
final expiresAt = DateTime.now().add(customTtl ?? ttl);
|
||||
final existing = _store.remove(key);
|
||||
if (existing != null) {
|
||||
_currentBytes -= existing.size;
|
||||
}
|
||||
_store[key] = _CacheEntry(value: value, expiresAt: expiresAt, size: size);
|
||||
_currentBytes += size;
|
||||
_puts++;
|
||||
_evictIfNeeded();
|
||||
}
|
||||
|
||||
void invalidate(String key) {
|
||||
final removed = _store.remove(key);
|
||||
if (removed != null) {
|
||||
_currentBytes -= removed.size;
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_store.clear();
|
||||
_currentBytes = 0;
|
||||
}
|
||||
|
||||
void _evictIfNeeded() {
|
||||
// 개수/용량 제한을 넘으면 오래된 것부터 제거
|
||||
while (_store.length > maxEntries || _currentBytes > maxBytes) {
|
||||
if (_store.isEmpty) break;
|
||||
final firstKey = _store.keys.first;
|
||||
final removed = _store.remove(firstKey);
|
||||
if (removed != null) {
|
||||
_currentBytes -= removed.size;
|
||||
_evictions++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, num> dumpMetrics() {
|
||||
final totalGets = _hits + _misses;
|
||||
final hitRate = totalGets == 0 ? 0 : _hits / totalGets;
|
||||
return {
|
||||
'entries': _store.length,
|
||||
'bytes': _currentBytes,
|
||||
'hits': _hits,
|
||||
'misses': _misses,
|
||||
'hitRate': hitRate,
|
||||
'puts': _puts,
|
||||
'evictions': _evictions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'exchange_rate_service.dart';
|
||||
import 'cache_manager.dart';
|
||||
|
||||
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
||||
class CurrencyUtil {
|
||||
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||
static final SimpleCacheManager<String> _fmtCache =
|
||||
SimpleCacheManager<String>(
|
||||
maxEntries: 256,
|
||||
maxBytes: 256 * 1024,
|
||||
ttl: const Duration(minutes: 15),
|
||||
);
|
||||
|
||||
/// 언어에 따른 기본 통화 반환
|
||||
static String getDefaultCurrency(String locale) {
|
||||
@@ -80,30 +87,46 @@ class CurrencyUtil {
|
||||
String currency,
|
||||
String locale,
|
||||
) async {
|
||||
// 캐시 조회
|
||||
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
|
||||
final key = 'fmt:$locale:$currency:${amount.toStringAsFixed(decimals)}';
|
||||
final cached = _fmtCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
final defaultCurrency = getDefaultCurrency(locale);
|
||||
|
||||
// 입력 통화가 기본 통화인 경우
|
||||
if (currency == defaultCurrency) {
|
||||
return _formatSingleCurrency(amount, currency);
|
||||
final result = _formatSingleCurrency(amount, currency);
|
||||
_fmtCache.set(key, result, size: result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
||||
if (currency == 'USD' && defaultCurrency != 'USD') {
|
||||
final convertedAmount = await _exchangeRateService.convertUsdToTarget(amount, defaultCurrency);
|
||||
final convertedAmount = await _exchangeRateService.convertUsdToTarget(
|
||||
amount, defaultCurrency);
|
||||
if (convertedAmount != null) {
|
||||
final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency);
|
||||
final primaryFormatted =
|
||||
_formatSingleCurrency(convertedAmount, defaultCurrency);
|
||||
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
||||
return '$primaryFormatted ($usdFormatted)';
|
||||
final result = '$primaryFormatted ($usdFormatted)';
|
||||
_fmtCache.set(key, result, size: result.length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 영어 사용자가 KRW 선택한 경우
|
||||
if (locale == 'en' && currency == 'KRW') {
|
||||
return _formatSingleCurrency(amount, currency);
|
||||
final result = _formatSingleCurrency(amount, currency);
|
||||
_fmtCache.set(key, result, size: result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 기타 통화 입력인 경우
|
||||
return _formatSingleCurrency(amount, currency);
|
||||
final result = _formatSingleCurrency(amount, currency);
|
||||
_fmtCache.set(key, result, size: result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
|
||||
@@ -139,7 +162,20 @@ class CurrencyUtil {
|
||||
static Future<String> formatSubscriptionAmountWithLocale(
|
||||
SubscriptionModel subscription, String locale) async {
|
||||
final price = subscription.currentPrice;
|
||||
return formatAmountWithLocale(price, subscription.currency, locale);
|
||||
// 구독 단위 캐시 키 (통화/가격/locale + id)
|
||||
final decimals =
|
||||
(subscription.currency == 'KRW' || subscription.currency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
final key =
|
||||
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}';
|
||||
final cached = _fmtCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
final result =
|
||||
await formatAmountWithLocale(price, subscription.currency, locale);
|
||||
_fmtCache.set(key, result, size: result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'cache_manager.dart';
|
||||
|
||||
/// 환율 정보 서비스 클래스
|
||||
class ExchangeRateService {
|
||||
@@ -15,18 +17,34 @@ class ExchangeRateService {
|
||||
// 내부 생성자
|
||||
ExchangeRateService._internal();
|
||||
|
||||
// 포맷된 환율 문자열 캐시 (언어별)
|
||||
static final SimpleCacheManager<String> _fmtCache =
|
||||
SimpleCacheManager<String>(
|
||||
maxEntries: 64,
|
||||
maxBytes: 64 * 1024,
|
||||
ttl: const Duration(minutes: 30),
|
||||
);
|
||||
|
||||
// 캐싱된 환율 정보
|
||||
double? _usdToKrwRate;
|
||||
double? _usdToJpyRate;
|
||||
double? _usdToCnyRate;
|
||||
DateTime? _lastUpdated;
|
||||
|
||||
// API 요청 URL (ExchangeRate-API 사용)
|
||||
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';
|
||||
// API 요청 URL (ExchangeRate-API 등) - 빌드 타임 오버라이드 가능
|
||||
static const String _defaultApiUrl =
|
||||
'https://api.exchangerate-api.com/v4/latest/USD';
|
||||
final String _apiUrl = const String.fromEnvironment(
|
||||
'EXCHANGE_RATE_API_URL',
|
||||
defaultValue: _defaultApiUrl,
|
||||
);
|
||||
|
||||
// 기본 환율 상수
|
||||
// ignore: constant_identifier_names
|
||||
static const double DEFAULT_USD_TO_KRW_RATE = 1350.0;
|
||||
// ignore: constant_identifier_names
|
||||
static const double DEFAULT_USD_TO_JPY_RATE = 150.0;
|
||||
// ignore: constant_identifier_names
|
||||
static const double DEFAULT_USD_TO_CNY_RATE = 7.2;
|
||||
|
||||
// 캐싱된 환율 반환 (동기적)
|
||||
@@ -44,18 +62,28 @@ class ExchangeRateService {
|
||||
}
|
||||
|
||||
try {
|
||||
// API 요청
|
||||
// API 요청 (네트워크 불가 환경에서는 예외 발생 가능)
|
||||
final response = await http.get(Uri.parse(_apiUrl));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
_usdToKrwRate = data['rates']['KRW']?.toDouble();
|
||||
_usdToJpyRate = data['rates']['JPY']?.toDouble();
|
||||
_usdToCnyRate = data['rates']['CNY']?.toDouble();
|
||||
_usdToKrwRate = (data['rates']['KRW'] as num?)?.toDouble();
|
||||
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
|
||||
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
|
||||
_lastUpdated = DateTime.now();
|
||||
// 환율 갱신 시 포맷 캐시 무효화
|
||||
_fmtCache.clear();
|
||||
Log.d(
|
||||
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
|
||||
return;
|
||||
} else {
|
||||
Log.w(
|
||||
'환율 API 응답 코드: ${response.statusCode} (${response.reasonPhrase})');
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 발생 시 기본값 사용
|
||||
} catch (e, st) {
|
||||
// 네트워크 실패 시 캐시/기본값 폴백
|
||||
Log.w('환율 API 요청 실패. 캐시/기본값 사용');
|
||||
Log.e('환율 API 에러', e, st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +103,8 @@ class ExchangeRateService {
|
||||
}
|
||||
|
||||
/// USD 금액을 지정된 통화로 변환합니다.
|
||||
Future<double?> convertUsdToTarget(double usdAmount, String targetCurrency) async {
|
||||
Future<double?> convertUsdToTarget(
|
||||
double usdAmount, String targetCurrency) async {
|
||||
await _fetchAllRatesIfNeeded();
|
||||
|
||||
switch (targetCurrency) {
|
||||
@@ -96,7 +125,8 @@ class ExchangeRateService {
|
||||
}
|
||||
|
||||
/// 지정된 통화를 USD로 변환합니다.
|
||||
Future<double?> convertTargetToUsd(double amount, String sourceCurrency) async {
|
||||
Future<double?> convertTargetToUsd(
|
||||
double amount, String sourceCurrency) async {
|
||||
await _fetchAllRatesIfNeeded();
|
||||
|
||||
switch (sourceCurrency) {
|
||||
@@ -118,10 +148,7 @@ class ExchangeRateService {
|
||||
|
||||
/// 두 통화 간 변환을 수행합니다. (USD를 거쳐서 변환)
|
||||
Future<double?> convertBetweenCurrencies(
|
||||
double amount,
|
||||
String fromCurrency,
|
||||
String toCurrency
|
||||
) async {
|
||||
double amount, String fromCurrency, String toCurrency) async {
|
||||
if (fromCurrency == toCurrency) {
|
||||
return amount;
|
||||
}
|
||||
@@ -161,32 +188,45 @@ class ExchangeRateService {
|
||||
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
||||
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
||||
await _fetchAllRatesIfNeeded();
|
||||
// 캐시 키 (locale 기준)
|
||||
final key = 'fx:fmt:$locale';
|
||||
final cached = _fmtCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
String result = '';
|
||||
switch (locale) {
|
||||
case 'ko':
|
||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||
return NumberFormat.currency(
|
||||
result = NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(rate);
|
||||
break;
|
||||
case 'ja':
|
||||
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
|
||||
return NumberFormat.currency(
|
||||
result = NumberFormat.currency(
|
||||
locale: 'ja_JP',
|
||||
symbol: '¥',
|
||||
decimalDigits: 0,
|
||||
).format(rate);
|
||||
break;
|
||||
case 'zh':
|
||||
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
|
||||
return NumberFormat.currency(
|
||||
result = NumberFormat.currency(
|
||||
locale: 'zh_CN',
|
||||
symbol: '¥',
|
||||
decimalDigits: 2,
|
||||
).format(rate);
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
result = '';
|
||||
break;
|
||||
}
|
||||
|
||||
// 대략적인 사이즈(문자 길이)로 캐시 저장
|
||||
_fmtCache.set(key, result, size: result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||
@@ -204,7 +244,8 @@ class ExchangeRateService {
|
||||
}
|
||||
|
||||
/// USD 금액을 지정된 언어의 기본 통화로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||
Future<String> getFormattedAmountForLocale(double usdAmount, String locale) async {
|
||||
Future<String> getFormattedAmountForLocale(
|
||||
double usdAmount, String locale) async {
|
||||
String targetCurrency;
|
||||
String localeCode;
|
||||
String symbol;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../models/subscription_model.dart';
|
||||
@@ -10,7 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
class NotificationService {
|
||||
static final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static final _secureStorage = const FlutterSecureStorage();
|
||||
static const _secureStorage = FlutterSecureStorage();
|
||||
|
||||
static const _notificationEnabledKey = 'notification_enabled';
|
||||
static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
|
||||
@@ -150,15 +149,14 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static Future<bool> requestPermission() async {
|
||||
// 웹 플랫폼인 경우 false 반환
|
||||
if (_isWeb) return false;
|
||||
|
||||
// iOS 처리
|
||||
if (Platform.isIOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final iosImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
@@ -173,13 +171,13 @@ class NotificationService {
|
||||
|
||||
// Android 처리
|
||||
if (Platform.isAndroid) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final androidImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
final granted =
|
||||
await androidImplementation.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
@@ -194,8 +192,8 @@ class NotificationService {
|
||||
|
||||
// Android 처리
|
||||
if (Platform.isAndroid) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final androidImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
@@ -207,8 +205,8 @@ class NotificationService {
|
||||
|
||||
// iOS 처리
|
||||
if (Platform.isIOS) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
final iosImplementation =
|
||||
_notifications.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
@@ -242,7 +240,7 @@ class NotificationService {
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
final iosDetails = const DarwinNotificationDetails();
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
@@ -267,10 +265,10 @@ class NotificationService {
|
||||
title,
|
||||
body,
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
androidAllowWhileIdle: true,
|
||||
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('알림 예약 중 오류 발생: $e');
|
||||
@@ -352,9 +350,9 @@ class NotificationService {
|
||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
||||
notificationDetails,
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||
@@ -417,9 +415,9 @@ class NotificationService {
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
@@ -457,7 +455,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
(subscription.id + '_expiration').hashCode,
|
||||
('${subscription.id}_expiration').hashCode,
|
||||
'구독 만료 예정 알림',
|
||||
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
@@ -470,9 +468,9 @@ class NotificationService {
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||
@@ -512,8 +510,9 @@ class NotificationService {
|
||||
}
|
||||
|
||||
// 기본 알림 예약 (지정된 일수 전)
|
||||
final scheduledDate =
|
||||
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith(
|
||||
final scheduledDate = subscription.nextBillingDate
|
||||
.subtract(Duration(days: reminderDays))
|
||||
.copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
@@ -536,12 +535,14 @@ class NotificationService {
|
||||
// 이벤트가 결제일 전에 종료되는 경우
|
||||
final eventPrice = subscription.eventPrice ?? 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)}원으로 변경됩니다.';
|
||||
} else {
|
||||
// 일반 알림
|
||||
final currentPrice = subscription.currentPrice;
|
||||
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
|
||||
notificationBody =
|
||||
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
@@ -568,7 +569,8 @@ class NotificationService {
|
||||
if (isDailyReminder && reminderDays >= 2) {
|
||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||
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,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
@@ -586,15 +588,19 @@ class NotificationService {
|
||||
String dailyNotificationBody;
|
||||
if (subscription.isEventActive &&
|
||||
subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
||||
subscription.eventEndDate!
|
||||
.isBefore(subscription.nextBillingDate) &&
|
||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
||||
final eventPrice =
|
||||
subscription.eventPrice ?? subscription.monthlyCost;
|
||||
final normalPrice = subscription.monthlyCost;
|
||||
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
|
||||
dailyNotificationBody =
|
||||
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
|
||||
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||
} else {
|
||||
final currentPrice = subscription.currentPrice;
|
||||
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
|
||||
dailyNotificationBody =
|
||||
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
|
||||
@@ -3,7 +3,8 @@ import '../../models/subscription_model.dart';
|
||||
|
||||
class SubscriptionConverter {
|
||||
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
||||
List<Subscription> convertModelsToSubscriptions(List<SubscriptionModel> models) {
|
||||
List<Subscription> convertModelsToSubscriptions(
|
||||
List<SubscriptionModel> models) {
|
||||
final result = <Subscription>[];
|
||||
|
||||
for (var model in models) {
|
||||
@@ -11,8 +12,12 @@ class SubscriptionConverter {
|
||||
final subscription = _convertSingle(model);
|
||||
result.add(subscription);
|
||||
|
||||
print('모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
|
||||
// 개발 편의를 위한 디버그 로그
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('모델 변환 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import '../../models/subscription.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
class SubscriptionFilter {
|
||||
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
||||
List<Subscription> filterDuplicates(
|
||||
List<Subscription> scanned, List<SubscriptionModel> existing) {
|
||||
print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
||||
Log.d(
|
||||
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
||||
|
||||
// 중복되지 않은 구독만 필터링
|
||||
return scanned.where((scannedSub) {
|
||||
@@ -16,7 +18,8 @@ class SubscriptionFilter {
|
||||
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
|
||||
|
||||
if (isSameName && isSameCost) {
|
||||
print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
|
||||
Log.d(
|
||||
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -27,7 +30,8 @@ class SubscriptionFilter {
|
||||
}
|
||||
|
||||
// 반복 횟수 기반 필터링
|
||||
List<Subscription> filterByRepeatCount(List<Subscription> subscriptions, int minCount) {
|
||||
List<Subscription> filterByRepeatCount(
|
||||
List<Subscription> subscriptions, int minCount) {
|
||||
return subscriptions.where((sub) => sub.repeatCount >= minCount).toList();
|
||||
}
|
||||
|
||||
@@ -44,7 +48,8 @@ class SubscriptionFilter {
|
||||
List<Subscription> filterByPriceRange(
|
||||
List<Subscription> subscriptions, double minPrice, double maxPrice) {
|
||||
return subscriptions
|
||||
.where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
|
||||
.where(
|
||||
(sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, compute;
|
||||
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../temp/test_sms_data.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../utils/platform_helper.dart';
|
||||
@@ -11,26 +12,26 @@ class SmsScanner {
|
||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||
try {
|
||||
List<dynamic> smsList;
|
||||
print('SmsScanner: 스캔 시작');
|
||||
Log.d('SmsScanner: 스캔 시작');
|
||||
|
||||
// 플랫폼별 분기 처리
|
||||
if (kIsWeb) {
|
||||
// 웹 환경: 테스트 데이터 사용
|
||||
print('SmsScanner: 웹 환경에서 테스트 데이터 사용');
|
||||
Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용');
|
||||
smsList = TestSmsData.getTestData();
|
||||
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
|
||||
Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
|
||||
} else if (PlatformHelper.isIOS) {
|
||||
// iOS: SMS 접근 불가, 빈 리스트 반환
|
||||
print('SmsScanner: iOS에서는 SMS 스캔 불가');
|
||||
Log.w('SmsScanner: iOS에서는 SMS 스캔 불가');
|
||||
return [];
|
||||
} else if (PlatformHelper.isAndroid) {
|
||||
// Android: flutter_sms_inbox 사용
|
||||
print('SmsScanner: Android에서 실제 SMS 스캔');
|
||||
Log.i('SmsScanner: Android에서 실제 SMS 스캔');
|
||||
smsList = await _scanAndroidSms();
|
||||
print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}');
|
||||
Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}');
|
||||
} else {
|
||||
// 기타 플랫폼
|
||||
print('SmsScanner: 지원하지 않는 플랫폼');
|
||||
Log.w('SmsScanner: 지원하지 않는 플랫폼');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -47,32 +48,32 @@ class SmsScanner {
|
||||
serviceGroups[serviceName]!.add(sms);
|
||||
}
|
||||
|
||||
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||
|
||||
// 그룹화된 데이터로 구독 분석
|
||||
for (final entry in serviceGroups.entries) {
|
||||
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
||||
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
||||
|
||||
// 2회 이상 반복된 서비스만 구독으로 간주
|
||||
if (entry.value.length >= 2) {
|
||||
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
||||
final subscription = _parseSms(serviceSms, entry.value.length);
|
||||
if (subscription != null) {
|
||||
print(
|
||||
Log.i(
|
||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||
subscriptions.add(subscription);
|
||||
} else {
|
||||
print('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||
}
|
||||
} else {
|
||||
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||
}
|
||||
}
|
||||
|
||||
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||
return subscriptions;
|
||||
} catch (e) {
|
||||
print('SmsScanner: 예외 발생: $e');
|
||||
Log.e('SmsScanner: 예외 발생', e);
|
||||
throw Exception('SMS 스캔 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
@@ -81,167 +82,36 @@ class SmsScanner {
|
||||
Future<List<dynamic>> _scanAndroidSms() async {
|
||||
try {
|
||||
final messages = await _query.getAllSms;
|
||||
final smsList = <Map<String, dynamic>>[];
|
||||
|
||||
// SMS 메시지를 분석하여 구독 서비스 감지
|
||||
// Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
|
||||
final serialized = <Map<String, dynamic>>[];
|
||||
for (final message in messages) {
|
||||
final parsedData = _parseRawSms(message);
|
||||
if (parsedData != null) {
|
||||
smsList.add(parsedData);
|
||||
}
|
||||
serialized.add({
|
||||
'body': message.body ?? '',
|
||||
'address': message.address ?? '',
|
||||
'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch,
|
||||
});
|
||||
}
|
||||
|
||||
// 대량 파싱은 별도 Isolate로 오프로딩
|
||||
final List<Map<String, dynamic>> smsList =
|
||||
await compute(_parseRawSmsBatch, serialized);
|
||||
|
||||
return smsList;
|
||||
} catch (e) {
|
||||
print('SmsScanner: Android SMS 스캔 실패: $e');
|
||||
Log.e('SmsScanner: Android SMS 스캔 실패', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 실제 SMS 메시지를 파싱하여 구독 정보 추출
|
||||
Map<String, dynamic>? _parseRawSms(SmsMessage message) {
|
||||
try {
|
||||
final body = message.body ?? '';
|
||||
final sender = message.address ?? '';
|
||||
final date = message.date ?? DateTime.now();
|
||||
|
||||
// 구독 서비스 키워드 매칭
|
||||
final subscriptionKeywords = [
|
||||
'구독', '결제', '정기결제', '자동결제', '월정액',
|
||||
'subscription', 'payment', 'billing', 'charge',
|
||||
'넷플릭스', 'Netflix', '유튜브', 'YouTube', 'Spotify',
|
||||
'멜론', '웨이브', 'Disney+', '디즈니플러스', 'Apple',
|
||||
'Microsoft', 'GitHub', 'Adobe', 'Amazon'
|
||||
];
|
||||
|
||||
// 구독 관련 키워드가 있는지 확인
|
||||
bool isSubscription = subscriptionKeywords.any((keyword) =>
|
||||
body.toLowerCase().contains(keyword.toLowerCase()) ||
|
||||
sender.toLowerCase().contains(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
if (!isSubscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 서비스명 추출
|
||||
String serviceName = _extractServiceName(body, sender);
|
||||
|
||||
// 금액 추출
|
||||
double? amount = _extractAmount(body);
|
||||
|
||||
// 결제 주기 추출
|
||||
String billingCycle = _extractBillingCycle(body);
|
||||
|
||||
return {
|
||||
'serviceName': serviceName,
|
||||
'monthlyCost': amount ?? 0.0,
|
||||
'billingCycle': billingCycle,
|
||||
'message': body,
|
||||
'nextBillingDate': _calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
|
||||
'previousPaymentDate': date.toIso8601String(),
|
||||
};
|
||||
} catch (e) {
|
||||
print('SmsScanner: SMS 파싱 실패: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스명 추출 로직
|
||||
String _extractServiceName(String body, String sender) {
|
||||
// 알려진 서비스 매핑
|
||||
final servicePatterns = {
|
||||
'netflix': '넷플릭스',
|
||||
'youtube': '유튜브 프리미엄',
|
||||
'spotify': 'Spotify',
|
||||
'disney': '디즈니플러스',
|
||||
'apple': 'Apple',
|
||||
'microsoft': 'Microsoft',
|
||||
'github': 'GitHub',
|
||||
'adobe': 'Adobe',
|
||||
'멜론': '멜론',
|
||||
'웨이브': '웨이브',
|
||||
};
|
||||
|
||||
// 메시지나 발신자에서 서비스명 찾기
|
||||
final combinedText = '$body $sender'.toLowerCase();
|
||||
|
||||
for (final entry in servicePatterns.entries) {
|
||||
if (combinedText.contains(entry.key)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 찾지 못한 경우
|
||||
return _extractServiceNameFromSender(sender);
|
||||
}
|
||||
|
||||
// 발신자 정보에서 서비스명 추출
|
||||
String _extractServiceNameFromSender(String sender) {
|
||||
// 숫자만 있으면 제거
|
||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||
return '알 수 없는 서비스';
|
||||
}
|
||||
|
||||
// 특수문자 제거하고 서비스명으로 사용
|
||||
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
|
||||
}
|
||||
|
||||
// 금액 추출 로직
|
||||
double? _extractAmount(String body) {
|
||||
// 다양한 금액 패턴 매칭
|
||||
final patterns = [
|
||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
||||
];
|
||||
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(body);
|
||||
if (match != null) {
|
||||
String amountStr = match.group(1) ?? '';
|
||||
amountStr = amountStr.replaceAll(',', '');
|
||||
return double.tryParse(amountStr);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 결제 주기 추출 로직
|
||||
String _extractBillingCycle(String body) {
|
||||
if (body.contains('월') || body.contains('monthly') || body.contains('매월')) {
|
||||
return 'monthly';
|
||||
} else if (body.contains('년') || body.contains('yearly') || body.contains('annual')) {
|
||||
return 'yearly';
|
||||
} else if (body.contains('주') || body.contains('weekly')) {
|
||||
return 'weekly';
|
||||
}
|
||||
|
||||
// 기본값
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
// 다음 결제일 계산
|
||||
DateTime _calculateNextBillingFromDate(DateTime lastDate, String billingCycle) {
|
||||
switch (billingCycle) {
|
||||
case 'monthly':
|
||||
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
|
||||
case 'yearly':
|
||||
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
|
||||
case 'weekly':
|
||||
return lastDate.add(const Duration(days: 7));
|
||||
default:
|
||||
return lastDate.add(const Duration(days: 30));
|
||||
}
|
||||
}
|
||||
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
|
||||
|
||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||
try {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||
final billingCycle = SubscriptionModel.normalizeBillingCycle(sms['billingCycle'] as String? ?? 'monthly');
|
||||
final billingCycle = SubscriptionModel.normalizeBillingCycle(
|
||||
sms['billingCycle'] as String? ?? 'monthly');
|
||||
final nextBillingDateStr = sms['nextBillingDate'] as String?;
|
||||
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
|
||||
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
|
||||
@@ -259,7 +129,7 @@ class SmsScanner {
|
||||
'Spotify Premium'
|
||||
];
|
||||
if (dollarServices.any((service) => serviceName.contains(service))) {
|
||||
print('서비스명 $serviceName으로 USD 통화 단위 확정');
|
||||
Log.d('서비스명 $serviceName으로 USD 통화 단위 확정');
|
||||
currency = 'USD';
|
||||
}
|
||||
|
||||
@@ -369,8 +239,6 @@ class SmsScanner {
|
||||
return serviceUrls[serviceName];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 메시지에서 통화 단위를 감지하는 함수
|
||||
String _detectCurrency(String message) {
|
||||
final dollarKeywords = [
|
||||
@@ -391,7 +259,7 @@ class SmsScanner {
|
||||
// 서비스명 기반 통화 단위 확인
|
||||
for (final service in serviceCurrencyMap.keys) {
|
||||
if (message.contains(service)) {
|
||||
print('_detectCurrency: ${service}는 USD 서비스로 판별됨');
|
||||
Log.d('_detectCurrency: $service는 USD 서비스로 판별됨');
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
@@ -399,7 +267,7 @@ class SmsScanner {
|
||||
// 메시지에 달러 관련 키워드가 있는지 확인
|
||||
for (final keyword in dollarKeywords) {
|
||||
if (message.toLowerCase().contains(keyword.toLowerCase())) {
|
||||
print('_detectCurrency: USD 키워드 발견: $keyword');
|
||||
Log.d('_detectCurrency: USD 키워드 발견: $keyword');
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
@@ -408,3 +276,148 @@ class SmsScanner {
|
||||
return 'KRW';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Isolate 오프로딩용 Top-level 파서 =====
|
||||
|
||||
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
List<Map<String, dynamic>> messages) {
|
||||
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
|
||||
const subscriptionKeywords = [
|
||||
'구독',
|
||||
'결제',
|
||||
'정기결제',
|
||||
'자동결제',
|
||||
'월정액',
|
||||
'subscription',
|
||||
'payment',
|
||||
'billing',
|
||||
'charge',
|
||||
'넷플릭스',
|
||||
'Netflix',
|
||||
'유튜브',
|
||||
'YouTube',
|
||||
'Spotify',
|
||||
'멜론',
|
||||
'웨이브',
|
||||
'Disney+',
|
||||
'디즈니플러스',
|
||||
'Apple',
|
||||
'Microsoft',
|
||||
'GitHub',
|
||||
'Adobe',
|
||||
'Amazon'
|
||||
];
|
||||
|
||||
final 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 results = <Map<String, dynamic>>[];
|
||||
|
||||
for (final m in messages) {
|
||||
final body = (m['body'] as String?) ?? '';
|
||||
final sender = (m['address'] as String?) ?? '';
|
||||
final dateMillis =
|
||||
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
|
||||
|
||||
final lowerBody = body.toLowerCase();
|
||||
final lowerSender = sender.toLowerCase();
|
||||
final isSubscription = subscriptionKeywords.any((k) =>
|
||||
lowerBody.contains(k.toLowerCase()) ||
|
||||
lowerSender.contains(k.toLowerCase()));
|
||||
|
||||
if (!isSubscription) continue;
|
||||
|
||||
final serviceName = _isoExtractServiceName(body, sender);
|
||||
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
|
||||
final billingCycle = _isoExtractBillingCycle(body);
|
||||
final nextBillingDate =
|
||||
_isoCalculateNextBillingFromDate(date, billingCycle);
|
||||
|
||||
results.add({
|
||||
'serviceName': serviceName,
|
||||
'monthlyCost': amount,
|
||||
'billingCycle': billingCycle,
|
||||
'message': body,
|
||||
'nextBillingDate': nextBillingDate.toIso8601String(),
|
||||
'previousPaymentDate': date.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
String _isoExtractServiceName(String body, String sender) {
|
||||
final servicePatterns = {
|
||||
'netflix': '넷플릭스',
|
||||
'youtube': '유튜브 프리미엄',
|
||||
'spotify': 'Spotify',
|
||||
'disney': '디즈니플러스',
|
||||
'apple': 'Apple',
|
||||
'microsoft': 'Microsoft',
|
||||
'github': 'GitHub',
|
||||
'adobe': 'Adobe',
|
||||
'멜론': '멜론',
|
||||
'웨이브': '웨이브',
|
||||
};
|
||||
|
||||
final combined = '$body $sender'.toLowerCase();
|
||||
for (final e in servicePatterns.entries) {
|
||||
if (combined.contains(e.key)) return e.value;
|
||||
}
|
||||
return _isoExtractServiceNameFromSender(sender);
|
||||
}
|
||||
|
||||
String _isoExtractServiceNameFromSender(String sender) {
|
||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||
return '알 수 없는 서비스';
|
||||
}
|
||||
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
|
||||
}
|
||||
|
||||
double? _isoExtractAmount(String body, List<RegExp> patterns) {
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(body);
|
||||
if (match != null) {
|
||||
var amountStr = match.group(1) ?? '';
|
||||
amountStr = amountStr.replaceAll(',', '');
|
||||
final parsed = double.tryParse(amountStr);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _isoExtractBillingCycle(String body) {
|
||||
if (body.contains('월') ||
|
||||
body.toLowerCase().contains('monthly') ||
|
||||
body.contains('매월')) {
|
||||
return 'monthly';
|
||||
} else if (body.contains('년') ||
|
||||
body.toLowerCase().contains('yearly') ||
|
||||
body.toLowerCase().contains('annual')) {
|
||||
return 'yearly';
|
||||
} else if (body.contains('주') || body.toLowerCase().contains('weekly')) {
|
||||
return 'weekly';
|
||||
}
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
DateTime _isoCalculateNextBillingFromDate(
|
||||
DateTime lastDate, String billingCycle) {
|
||||
switch (billingCycle) {
|
||||
case 'monthly':
|
||||
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
|
||||
case 'yearly':
|
||||
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
|
||||
case 'weekly':
|
||||
return lastDate.add(const Duration(days: 7));
|
||||
default:
|
||||
return lastDate.add(const Duration(days: 30));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ class SubscriptionUrlMatcher {
|
||||
// 2. 서비스 초기화
|
||||
_categoryMapper = CategoryMapperService(_dataRepository!);
|
||||
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
|
||||
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!);
|
||||
_cancellationService =
|
||||
CancellationUrlService(_dataRepository!, _urlMatcher!);
|
||||
_nameResolver = ServiceNameResolver(_dataRepository!);
|
||||
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
|
||||
}
|
||||
@@ -67,7 +68,8 @@ class SubscriptionUrlMatcher {
|
||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
||||
await initialize();
|
||||
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false;
|
||||
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// 서비스명으로 카테고리 찾기
|
||||
@@ -85,7 +87,8 @@ class SubscriptionUrlMatcher {
|
||||
return await _nameResolver?.getServiceDisplayName(
|
||||
serviceName: serviceName,
|
||||
locale: locale,
|
||||
) ?? serviceName;
|
||||
) ??
|
||||
serviceName;
|
||||
}
|
||||
|
||||
/// SMS에서 URL과 서비스 정보 추출
|
||||
|
||||
@@ -1,941 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 서비스 정보를 담는 데이터 클래스
|
||||
class ServiceInfo {
|
||||
final String serviceId;
|
||||
final String serviceName;
|
||||
final String? serviceUrl;
|
||||
final String? cancellationUrl;
|
||||
final String categoryId;
|
||||
final String categoryNameKr;
|
||||
final String categoryNameEn;
|
||||
|
||||
ServiceInfo({
|
||||
required this.serviceId,
|
||||
required this.serviceName,
|
||||
this.serviceUrl,
|
||||
this.cancellationUrl,
|
||||
required this.categoryId,
|
||||
required this.categoryNameKr,
|
||||
required this.categoryNameEn,
|
||||
});
|
||||
}
|
||||
|
||||
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
|
||||
class SubscriptionUrlMatcher {
|
||||
static Map<String, dynamic>? _servicesData;
|
||||
static bool _isInitialized = false;
|
||||
|
||||
// 레거시 데이터 (JSON 로드 실패시 폴백)
|
||||
// OTT 서비스
|
||||
static final Map<String, String> ottServices = {
|
||||
'netflix': 'https://www.netflix.com',
|
||||
'넷플릭스': 'https://www.netflix.com',
|
||||
'disney+': 'https://www.disneyplus.com',
|
||||
'디즈니플러스': 'https://www.disneyplus.com',
|
||||
'youtube premium': 'https://www.youtube.com/premium',
|
||||
'유튜브 프리미엄': 'https://www.youtube.com/premium',
|
||||
'watcha': 'https://watcha.com',
|
||||
'왓챠': 'https://watcha.com',
|
||||
'wavve': 'https://www.wavve.com',
|
||||
'웨이브': 'https://www.wavve.com',
|
||||
'apple tv+': 'https://tv.apple.com',
|
||||
'애플 티비플러스': 'https://tv.apple.com',
|
||||
'tving': 'https://www.tving.com',
|
||||
'티빙': 'https://www.tving.com',
|
||||
'prime video': 'https://www.primevideo.com',
|
||||
'프라임 비디오': 'https://www.primevideo.com',
|
||||
'amazon prime': 'https://www.amazon.com/prime',
|
||||
'아마존 프라임': 'https://www.amazon.com/prime',
|
||||
'coupang play': 'https://play.coupangplay.com',
|
||||
'쿠팡 플레이': 'https://play.coupangplay.com',
|
||||
'hulu': 'https://www.hulu.com',
|
||||
'훌루': 'https://www.hulu.com',
|
||||
};
|
||||
|
||||
// 음악 서비스
|
||||
static final Map<String, String> musicServices = {
|
||||
'spotify': 'https://www.spotify.com',
|
||||
'스포티파이': 'https://www.spotify.com',
|
||||
'apple music': 'https://music.apple.com',
|
||||
'애플 뮤직': 'https://music.apple.com',
|
||||
'melon': 'https://www.melon.com',
|
||||
'멜론': 'https://www.melon.com',
|
||||
'genie': 'https://www.genie.co.kr',
|
||||
'지니': 'https://www.genie.co.kr',
|
||||
'youtube music': 'https://music.youtube.com',
|
||||
'유튜브 뮤직': 'https://music.youtube.com',
|
||||
'bugs': 'https://music.bugs.co.kr',
|
||||
'벅스': 'https://music.bugs.co.kr',
|
||||
'flo': 'https://www.music-flo.com',
|
||||
'플로': 'https://www.music-flo.com',
|
||||
'vibe': 'https://vibe.naver.com',
|
||||
'바이브': 'https://vibe.naver.com',
|
||||
'tidal': 'https://www.tidal.com',
|
||||
'타이달': 'https://www.tidal.com',
|
||||
};
|
||||
|
||||
// 저장 (클라우드/파일) 서비스
|
||||
static final Map<String, String> storageServices = {
|
||||
'google drive': 'https://www.google.com/drive/',
|
||||
'구글 드라이브': 'https://www.google.com/drive/',
|
||||
'dropbox': 'https://www.dropbox.com',
|
||||
'드롭박스': 'https://www.dropbox.com',
|
||||
'onedrive': 'https://www.onedrive.com',
|
||||
'원드라이브': 'https://www.onedrive.com',
|
||||
'icloud': 'https://www.icloud.com',
|
||||
'아이클라우드': 'https://www.icloud.com',
|
||||
'box': 'https://www.box.com',
|
||||
'박스': 'https://www.box.com',
|
||||
'pcloud': 'https://www.pcloud.com',
|
||||
'mega': 'https://mega.nz',
|
||||
'메가': 'https://mega.nz',
|
||||
'naver mybox': 'https://mybox.naver.com',
|
||||
'네이버 마이박스': 'https://mybox.naver.com',
|
||||
};
|
||||
|
||||
// 통신 · 인터넷 · TV 서비스
|
||||
static final Map<String, String> telecomServices = {
|
||||
'skt': 'https://www.sktelecom.com',
|
||||
'sk텔레콤': 'https://www.sktelecom.com',
|
||||
'kt': 'https://www.kt.com',
|
||||
'lgu+': 'https://www.lguplus.com',
|
||||
'lg유플러스': 'https://www.lguplus.com',
|
||||
'olleh tv': 'https://www.kt.com/olleh_tv',
|
||||
'올레 tv': 'https://www.kt.com/olleh_tv',
|
||||
'b tv': 'https://www.skbroadband.com',
|
||||
'비티비': 'https://www.skbroadband.com',
|
||||
'u+모바일tv': 'https://www.lguplus.com',
|
||||
'유플러스모바일tv': 'https://www.lguplus.com',
|
||||
};
|
||||
|
||||
// 생활/라이프스타일 서비스
|
||||
static final Map<String, String> lifestyleServices = {
|
||||
'네이버 플러스': 'https://plus.naver.com',
|
||||
'naver plus': 'https://plus.naver.com',
|
||||
'카카오 구독': 'https://subscribe.kakao.com',
|
||||
'kakao subscribe': 'https://subscribe.kakao.com',
|
||||
'쿠팡 와우': 'https://www.coupang.com/np/coupangplus',
|
||||
'coupang wow': 'https://www.coupang.com/np/coupangplus',
|
||||
'스타벅스 버디': 'https://www.starbucks.co.kr',
|
||||
'starbucks buddy': 'https://www.starbucks.co.kr',
|
||||
'cu 구독': 'https://cu.bgfretail.com',
|
||||
'gs25 구독': 'https://gs25.gsretail.com',
|
||||
'현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription',
|
||||
'lg전자 구독': 'https://www.lge.co.kr',
|
||||
'삼성전자 구독': 'https://www.samsung.com/sec',
|
||||
'다이슨 케어': 'https://www.dyson.co.kr',
|
||||
'dyson care': 'https://www.dyson.co.kr',
|
||||
'마켓컬리': 'https://www.kurly.com',
|
||||
'kurly': 'https://www.kurly.com',
|
||||
'헬로네이처': 'https://www.hellonature.com',
|
||||
'hello nature': 'https://www.hellonature.com',
|
||||
'이마트 트레이더스': 'https://www.emarttraders.co.kr',
|
||||
'홈플러스': 'https://www.homeplus.co.kr',
|
||||
'hellofresh': 'https://www.hellofresh.com',
|
||||
'헬로프레시': 'https://www.hellofresh.com',
|
||||
'bespoke post': 'https://www.bespokepost.com',
|
||||
};
|
||||
|
||||
// 쇼핑/이커머스 서비스
|
||||
static final Map<String, String> shoppingServices = {
|
||||
'amazon prime': 'https://www.amazon.com/prime',
|
||||
'아마존 프라임': 'https://www.amazon.com/prime',
|
||||
'walmart+': 'https://www.walmart.com/plus',
|
||||
'월마트플러스': 'https://www.walmart.com/plus',
|
||||
'chewy': 'https://www.chewy.com',
|
||||
'츄이': 'https://www.chewy.com',
|
||||
'dollar shave club': 'https://www.dollarshaveclub.com',
|
||||
'달러셰이브클럽': 'https://www.dollarshaveclub.com',
|
||||
'instacart': 'https://www.instacart.com',
|
||||
'인스타카트': 'https://www.instacart.com',
|
||||
'shipt': 'https://www.shipt.com',
|
||||
'십트': 'https://www.shipt.com',
|
||||
'grove': 'https://grove.co',
|
||||
'그로브': 'https://grove.co',
|
||||
'cratejoy': 'https://www.cratejoy.com',
|
||||
'shopify': 'https://www.shopify.com',
|
||||
'쇼피파이': 'https://www.shopify.com',
|
||||
};
|
||||
|
||||
// AI 서비스
|
||||
static final Map<String, String> aiServices = {
|
||||
'chatgpt': 'https://chat.openai.com',
|
||||
'챗GPT': 'https://chat.openai.com',
|
||||
'openai': 'https://openai.com',
|
||||
'오픈AI': 'https://openai.com',
|
||||
'claude': 'https://claude.ai',
|
||||
'클로드': 'https://claude.ai',
|
||||
'anthropic': 'https://www.anthropic.com',
|
||||
'앤트로픽': 'https://www.anthropic.com',
|
||||
'midjourney': 'https://www.midjourney.com',
|
||||
'미드저니': 'https://www.midjourney.com',
|
||||
'perplexity': 'https://www.perplexity.ai',
|
||||
'퍼플렉시티': 'https://www.perplexity.ai',
|
||||
'copilot': 'https://copilot.microsoft.com',
|
||||
'코파일럿': 'https://copilot.microsoft.com',
|
||||
'gemini': 'https://gemini.google.com',
|
||||
'제미니': 'https://gemini.google.com',
|
||||
'google ai': 'https://ai.google',
|
||||
'구글 AI': 'https://ai.google',
|
||||
'bard': 'https://bard.google.com',
|
||||
'바드': 'https://bard.google.com',
|
||||
'dall-e': 'https://openai.com/dall-e',
|
||||
'달리': 'https://openai.com/dall-e',
|
||||
'stable diffusion': 'https://stability.ai',
|
||||
'스테이블 디퓨전': 'https://stability.ai',
|
||||
};
|
||||
|
||||
// 프로그래밍 / 개발 서비스
|
||||
static final Map<String, String> programmingServices = {
|
||||
'github': 'https://github.com',
|
||||
'깃허브': 'https://github.com',
|
||||
'cursor': 'https://cursor.com',
|
||||
'커서': 'https://cursor.com',
|
||||
'jetbrains': 'https://www.jetbrains.com',
|
||||
'제트브레인스': 'https://www.jetbrains.com',
|
||||
'intellij': 'https://www.jetbrains.com/idea',
|
||||
'인텔리제이': 'https://www.jetbrains.com/idea',
|
||||
'visual studio': 'https://visualstudio.microsoft.com',
|
||||
'비주얼 스튜디오': 'https://visualstudio.microsoft.com',
|
||||
'aws': 'https://aws.amazon.com',
|
||||
'아마존 웹서비스': 'https://aws.amazon.com',
|
||||
'azure': 'https://azure.microsoft.com',
|
||||
'애저': 'https://azure.microsoft.com',
|
||||
'google cloud': 'https://cloud.google.com',
|
||||
'구글 클라우드': 'https://cloud.google.com',
|
||||
'digitalocean': 'https://www.digitalocean.com',
|
||||
'디지털오션': 'https://www.digitalocean.com',
|
||||
'heroku': 'https://www.heroku.com',
|
||||
'헤로쿠': 'https://www.heroku.com',
|
||||
'codecademy': 'https://www.codecademy.com',
|
||||
'코드아카데미': 'https://www.codecademy.com',
|
||||
'udemy': 'https://www.udemy.com',
|
||||
'유데미': 'https://www.udemy.com',
|
||||
'coursera': 'https://www.coursera.org',
|
||||
'코세라': 'https://www.coursera.org',
|
||||
};
|
||||
|
||||
// 오피스 및 협업 툴
|
||||
static final Map<String, String> officeTools = {
|
||||
'microsoft 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'마이크로소프트 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'office 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'오피스 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'google workspace': 'https://workspace.google.com',
|
||||
'구글 워크스페이스': 'https://workspace.google.com',
|
||||
'slack': 'https://slack.com',
|
||||
'슬랙': 'https://slack.com',
|
||||
'notion': 'https://www.notion.so',
|
||||
'노션': 'https://www.notion.so',
|
||||
'trello': 'https://trello.com',
|
||||
'트렐로': 'https://trello.com',
|
||||
'asana': 'https://asana.com',
|
||||
'아사나': 'https://asana.com',
|
||||
'dropbox': 'https://www.dropbox.com',
|
||||
'드롭박스': 'https://www.dropbox.com',
|
||||
'figma': 'https://www.figma.com',
|
||||
'피그마': 'https://www.figma.com',
|
||||
'adobe creative cloud': 'https://www.adobe.com/creativecloud.html',
|
||||
'어도비 크리에이티브 클라우드': 'https://www.adobe.com/creativecloud.html',
|
||||
};
|
||||
|
||||
// 기타 유명 서비스
|
||||
static final Map<String, String> otherServices = {
|
||||
'google one': 'https://one.google.com',
|
||||
'구글 원': 'https://one.google.com',
|
||||
'icloud': 'https://www.icloud.com',
|
||||
'아이클라우드': 'https://www.icloud.com',
|
||||
'nintendo switch online': 'https://www.nintendo.com/switch/online-service',
|
||||
'닌텐도 스위치 온라인': 'https://www.nintendo.com/switch/online-service',
|
||||
'playstation plus': 'https://www.playstation.com/ps-plus',
|
||||
'플레이스테이션 플러스': 'https://www.playstation.com/ps-plus',
|
||||
'xbox game pass': 'https://www.xbox.com/xbox-game-pass',
|
||||
'엑스박스 게임 패스': 'https://www.xbox.com/xbox-game-pass',
|
||||
'ea play': 'https://www.ea.com/ea-play',
|
||||
'EA 플레이': 'https://www.ea.com/ea-play',
|
||||
'ubisoft+': 'https://ubisoft.com/plus',
|
||||
'유비소프트+': 'https://ubisoft.com/plus',
|
||||
'epic games': 'https://www.epicgames.com',
|
||||
'에픽 게임즈': 'https://www.epicgames.com',
|
||||
'steam': 'https://store.steampowered.com',
|
||||
'스팀': 'https://store.steampowered.com',
|
||||
};
|
||||
|
||||
// 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들)
|
||||
static final Map<String, String> cancellationUrls = {
|
||||
// OTT 서비스 해지 안내 페이지
|
||||
'netflix': 'https://help.netflix.com/ko/node/407',
|
||||
'넷플릭스': 'https://help.netflix.com/ko/node/407',
|
||||
'disney+':
|
||||
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
|
||||
'디즈니플러스':
|
||||
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
|
||||
'youtube premium': 'https://support.google.com/youtube/answer/6308278',
|
||||
'유튜브 프리미엄': 'https://support.google.com/youtube/answer/6308278',
|
||||
'watcha': 'https://watcha.com/settings/payment',
|
||||
'왓챠': 'https://watcha.com/settings/payment',
|
||||
'wavve': 'https://www.wavve.com/my',
|
||||
'웨이브': 'https://www.wavve.com/my',
|
||||
'apple tv+': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'애플 티비플러스': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'tving': 'https://www.tving.com/my/cancelMembership',
|
||||
'티빙': 'https://www.tving.com/my/cancelMembership',
|
||||
'amazon prime': 'https://www.amazon.com/gp/primecentral/managemembership',
|
||||
'아마존 프라임': 'https://www.amazon.com/gp/primecentral/managemembership',
|
||||
|
||||
// 음악 서비스 해지 안내 페이지
|
||||
'spotify': 'https://support.spotify.com/us/article/cancel-premium/',
|
||||
'스포티파이': 'https://support.spotify.com/us/article/cancel-premium/',
|
||||
'apple music': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'애플 뮤직': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'melon':
|
||||
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
|
||||
'멜론':
|
||||
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
|
||||
'youtube music': 'https://support.google.com/youtubemusic/answer/6308278',
|
||||
'유튜브 뮤직': 'https://support.google.com/youtubemusic/answer/6308278',
|
||||
|
||||
// AI 서비스 해지 안내 페이지
|
||||
'chatgpt':
|
||||
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
|
||||
'챗GPT':
|
||||
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
|
||||
'claude':
|
||||
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
|
||||
'클로드':
|
||||
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
|
||||
'midjourney': 'https://docs.midjourney.com/docs/manage-subscription',
|
||||
'미드저니': 'https://docs.midjourney.com/docs/manage-subscription',
|
||||
|
||||
// 프로그래밍 / 개발 서비스 해지 안내 페이지
|
||||
'github':
|
||||
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
|
||||
'깃허브':
|
||||
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
|
||||
'jetbrains':
|
||||
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
|
||||
'제트브레인스':
|
||||
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
|
||||
|
||||
// 오피스 및 협업 툴 해지 안내 페이지
|
||||
'microsoft 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'마이크로소프트 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'office 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'오피스 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'slack':
|
||||
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
|
||||
'슬랙':
|
||||
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
|
||||
'notion':
|
||||
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
|
||||
'노션':
|
||||
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
|
||||
'dropbox': 'https://help.dropbox.com/accounts-billing/cancellation',
|
||||
'드롭박스': 'https://help.dropbox.com/accounts-billing/cancellation',
|
||||
'adobe creative cloud':
|
||||
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
|
||||
'어도비 크리에이티브 클라우드':
|
||||
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
|
||||
|
||||
// 기타 유명 서비스 해지 안내 페이지
|
||||
'google one': 'https://support.google.com/googleone/answer/9140429',
|
||||
'구글 원': 'https://support.google.com/googleone/answer/9140429',
|
||||
'icloud': 'https://support.apple.com/ko-kr/HT207594',
|
||||
'아이클라우드': 'https://support.apple.com/ko-kr/HT207594',
|
||||
'nintendo switch online':
|
||||
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
|
||||
'닌텐도 스위치 온라인':
|
||||
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
|
||||
'playstation plus':
|
||||
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
|
||||
'플레이스테이션 플러스':
|
||||
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
|
||||
'xbox game pass':
|
||||
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
|
||||
'엑스박스 게임 패스':
|
||||
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
|
||||
};
|
||||
|
||||
// 모든 서비스 매핑을 합친 맵
|
||||
static final Map<String, String> allServices = {
|
||||
...ottServices,
|
||||
...musicServices,
|
||||
...storageServices,
|
||||
...aiServices,
|
||||
...programmingServices,
|
||||
...officeTools,
|
||||
...lifestyleServices,
|
||||
...shoppingServices,
|
||||
...telecomServices,
|
||||
...otherServices,
|
||||
};
|
||||
|
||||
/// JSON 데이터 초기화
|
||||
static Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
|
||||
_servicesData = json.decode(jsonString);
|
||||
_isInitialized = true;
|
||||
print('SubscriptionUrlMatcher: JSON 데이터 로드 완료');
|
||||
} catch (e) {
|
||||
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e');
|
||||
// 로드 실패시 기존 하드코딩 데이터 사용
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 도메인 추출 (www와 TLD 제외)
|
||||
static String? extractDomain(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final host = uri.host.toLowerCase();
|
||||
|
||||
// 도메인 부분 추출
|
||||
var parts = host.split('.');
|
||||
|
||||
// www 제거
|
||||
if (parts.isNotEmpty && parts[0] == 'www') {
|
||||
parts = parts.sublist(1);
|
||||
}
|
||||
|
||||
// 서브도메인 처리 (예: music.youtube.com)
|
||||
if (parts.length >= 3) {
|
||||
// 서브도메인 포함 전체 도메인 반환
|
||||
return parts.sublist(0, parts.length - 1).join('.');
|
||||
} else if (parts.length >= 2) {
|
||||
// 메인 도메인만 반환
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('SubscriptionUrlMatcher: 도메인 추출 실패 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// URL로 서비스 찾기
|
||||
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
||||
await initialize();
|
||||
|
||||
final domain = extractDomain(url);
|
||||
if (domain == null) return null;
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
if (_servicesData != null) {
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
for (final categoryEntry in categories.entries) {
|
||||
final categoryId = categoryEntry.key;
|
||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||
final services = categoryData['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceEntry in services.entries) {
|
||||
final serviceId = serviceEntry.key;
|
||||
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
||||
final domains = List<String>.from(serviceData['domains'] ?? []);
|
||||
|
||||
// 도메인이 일치하는지 확인
|
||||
for (final serviceDomain in domains) {
|
||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||
final names = List<String>.from(serviceData['names'] ?? []);
|
||||
final urls = serviceData['urls'] as Map<String, dynamic>?;
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: serviceId,
|
||||
serviceName: names.isNotEmpty ? names[0] : serviceId,
|
||||
serviceUrl: urls?['kr'] ?? urls?['en'],
|
||||
cancellationUrl: null,
|
||||
categoryId: _getCategoryIdByKey(categoryId),
|
||||
categoryNameKr: categoryData['nameKr'] ?? '',
|
||||
categoryNameEn: categoryData['nameEn'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
for (final entry in allServices.entries) {
|
||||
final serviceUrl = entry.value;
|
||||
final serviceDomain = extractDomain(serviceUrl);
|
||||
|
||||
if (serviceDomain != null &&
|
||||
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
serviceName: entry.key,
|
||||
serviceUrl: serviceUrl,
|
||||
cancellationUrl: null,
|
||||
categoryId: _getCategoryForLegacyService(entry.key),
|
||||
categoryNameKr: '',
|
||||
categoryNameEn: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
||||
static String? suggestUrl(String serviceName) {
|
||||
if (serviceName.isEmpty) {
|
||||
print('SubscriptionUrlMatcher: 빈 serviceName');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 비교
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
// 정확한 매칭을 먼저 시도
|
||||
for (final entry in allServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// OTT 서비스 검사
|
||||
for (final entry in ottServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 음악 서비스 검사
|
||||
for (final entry in musicServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// AI 서비스 검사
|
||||
for (final entry in aiServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 서비스 검사
|
||||
for (final entry in programmingServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 오피스 툴 검사
|
||||
for (final entry in officeTools.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 서비스 검사
|
||||
for (final entry in otherServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색
|
||||
for (final entry in allServices.entries) {
|
||||
final serviceWords = lowerName.split(' ');
|
||||
final keyWords = entry.key.toLowerCase().split(' ');
|
||||
|
||||
// 단어 단위로 일치하는지 확인
|
||||
for (final word in serviceWords) {
|
||||
if (word.length > 2 &&
|
||||
keyWords.any((keyWord) => keyWord.contains(word))) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 단어 기반 매칭 - $word (in $lowerName) -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 추출 가능한 도메인이 있는지 확인
|
||||
final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName);
|
||||
if (domainMatch != null && domainMatch.group(1)!.length > 2) {
|
||||
final domain = domainMatch.group(1)!.trim();
|
||||
if (domain.length > 2 &&
|
||||
!['the', 'and', 'for', 'www'].contains(domain)) {
|
||||
final url = 'https://www.$domain.com';
|
||||
print('SubscriptionUrlMatcher: 도메인 추출 - $lowerName -> $url');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
print('SubscriptionUrlMatcher: 매칭 실패 - $lowerName');
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('SubscriptionUrlMatcher: URL 매칭 중 오류 발생: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 해지 안내 URL 찾기 (개선된 버전)
|
||||
static Future<String?> findCancellationUrl({
|
||||
String? serviceName,
|
||||
String? websiteUrl,
|
||||
String locale = 'kr',
|
||||
}) async {
|
||||
await initialize();
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
if (_servicesData != null) {
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
// 1. 서비스명으로 찾기
|
||||
if (serviceName != null && serviceName.isNotEmpty) {
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
|
||||
for (final name in names) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. URL로 찾기
|
||||
if (websiteUrl != null && websiteUrl.isNotEmpty) {
|
||||
final domain = extractDomain(websiteUrl);
|
||||
if (domain != null) {
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
|
||||
|
||||
for (final serviceDomain in domains) {
|
||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
||||
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
||||
}
|
||||
|
||||
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시)
|
||||
static String? _findCancellationUrlLegacy(String serviceNameOrUrl) {
|
||||
if (serviceNameOrUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = serviceNameOrUrl.toLowerCase().trim();
|
||||
|
||||
// 직접 서비스명으로 찾기
|
||||
if (cancellationUrls.containsKey(lowerText)) {
|
||||
return cancellationUrls[lowerText];
|
||||
}
|
||||
|
||||
// 서비스명에 부분 포함으로 찾기
|
||||
for (var entry in cancellationUrls.entries) {
|
||||
final String key = entry.key.toLowerCase();
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// URL을 통해 서비스명 추출 후 찾기
|
||||
if (lowerText.startsWith('http')) {
|
||||
// URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출)
|
||||
final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)');
|
||||
final match = domainRegex.firstMatch(lowerText);
|
||||
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
final domain = match.group(1)?.toLowerCase() ?? '';
|
||||
|
||||
// 도메인으로 서비스명 찾기
|
||||
for (var entry in cancellationUrls.entries) {
|
||||
if (entry.key.toLowerCase().contains(domain)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 해지 안내 페이지를 찾지 못함
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
||||
// 새로운 JSON 기반 방식으로 확인
|
||||
final cancellationUrl = await findCancellationUrl(
|
||||
serviceName: serviceNameOrUrl,
|
||||
websiteUrl: serviceNameOrUrl,
|
||||
);
|
||||
return cancellationUrl != null;
|
||||
}
|
||||
|
||||
/// 서비스명으로 카테고리 찾기
|
||||
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
||||
await initialize();
|
||||
if (serviceName.isEmpty) return null;
|
||||
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
// JSON 데이터가 있으면 JSON에서 찾기
|
||||
if (_servicesData != null) {
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
for (final categoryEntry in categories.entries) {
|
||||
final categoryId = categoryEntry.key;
|
||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
||||
final services = categoryData['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
|
||||
for (final name in names) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
return _getCategoryIdByKey(categoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
||||
return _getCategoryForLegacyService(serviceName);
|
||||
}
|
||||
|
||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
||||
static Future<String> getServiceDisplayName({
|
||||
required String serviceName,
|
||||
required String locale,
|
||||
}) async {
|
||||
await initialize();
|
||||
|
||||
if (_servicesData == null) {
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
||||
|
||||
// JSON에서 서비스 찾기
|
||||
for (final categoryData in categories.values) {
|
||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final data = serviceData as Map<String, dynamic>;
|
||||
final names = List<String>.from(data['names'] ?? []);
|
||||
|
||||
// names 배열에 있는지 확인
|
||||
for (final name in names) {
|
||||
if (lowerName == name.toLowerCase() ||
|
||||
lowerName.contains(name.toLowerCase()) ||
|
||||
name.toLowerCase().contains(lowerName)) {
|
||||
// 로케일에 따라 적절한 이름 반환
|
||||
if (locale == 'ko' || locale == 'kr') {
|
||||
return data['nameKr'] ?? serviceName;
|
||||
} else {
|
||||
return data['nameEn'] ?? serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nameKr/nameEn에 직접 매칭 확인
|
||||
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
|
||||
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
|
||||
|
||||
if (lowerName == nameKr || lowerName == nameEn) {
|
||||
if (locale == 'ko' || locale == 'kr') {
|
||||
return data['nameKr'] ?? serviceName;
|
||||
} else {
|
||||
return data['nameEn'] ?? serviceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 찾지 못한 경우 원래 이름 반환
|
||||
return serviceName;
|
||||
}
|
||||
|
||||
/// 카테고리 키를 실제 카테고리 ID로 매핑
|
||||
static String _getCategoryIdByKey(String key) {
|
||||
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
|
||||
// 임시로 카테고리명 기반 매핑
|
||||
switch (key) {
|
||||
case 'music':
|
||||
return 'music_streaming';
|
||||
case 'ott':
|
||||
return 'ott_services';
|
||||
case 'storage':
|
||||
return 'cloud_storage';
|
||||
case 'ai':
|
||||
return 'ai_services';
|
||||
case 'programming':
|
||||
return 'dev_tools';
|
||||
case 'office':
|
||||
return 'office_tools';
|
||||
case 'lifestyle':
|
||||
return 'lifestyle';
|
||||
case 'shopping':
|
||||
return 'shopping';
|
||||
case 'gaming':
|
||||
return 'gaming';
|
||||
case 'telecom':
|
||||
return 'telecom';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
/// 레거시 서비스명으로 카테고리 추측
|
||||
static String _getCategoryForLegacyService(String serviceName) {
|
||||
final lowerName = serviceName.toLowerCase();
|
||||
|
||||
if (ottServices.containsKey(lowerName)) return 'ott_services';
|
||||
if (musicServices.containsKey(lowerName)) return 'music_streaming';
|
||||
if (storageServices.containsKey(lowerName)) return 'cloud_storage';
|
||||
if (aiServices.containsKey(lowerName)) return 'ai_services';
|
||||
if (programmingServices.containsKey(lowerName)) return 'dev_tools';
|
||||
if (officeTools.containsKey(lowerName)) return 'office_tools';
|
||||
if (lifestyleServices.containsKey(lowerName)) return 'lifestyle';
|
||||
if (shoppingServices.containsKey(lowerName)) return 'shopping';
|
||||
if (telecomServices.containsKey(lowerName)) return 'telecom';
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/// SMS에서 URL과 서비스 정보 추출
|
||||
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
||||
await initialize();
|
||||
|
||||
// URL 패턴 찾기
|
||||
final urlPattern = RegExp(
|
||||
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final matches = urlPattern.allMatches(smsText);
|
||||
|
||||
for (final match in matches) {
|
||||
final url = match.group(0);
|
||||
if (url != null) {
|
||||
final serviceInfo = await findServiceByUrl(url);
|
||||
if (serviceInfo != null) {
|
||||
return serviceInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL로 못 찾았으면 서비스명으로 시도
|
||||
final lowerSms = smsText.toLowerCase();
|
||||
|
||||
// 모든 서비스명 검사
|
||||
for (final entry in allServices.entries) {
|
||||
if (lowerSms.contains(entry.key.toLowerCase())) {
|
||||
final categoryId = await findCategoryByServiceName(entry.key) ?? 'other';
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
serviceName: entry.key,
|
||||
serviceUrl: entry.value,
|
||||
cancellationUrl: null,
|
||||
categoryId: categoryId,
|
||||
categoryNameKr: '',
|
||||
categoryNameEn: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// URL이 알려진 서비스 URL인지 확인
|
||||
static Future<bool> isKnownServiceUrl(String url) async {
|
||||
final serviceInfo = await findServiceByUrl(url);
|
||||
return serviceInfo != null;
|
||||
}
|
||||
|
||||
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
|
||||
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||
// 입력 텍스트가 비어있거나 null인 경우
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = text.toLowerCase().trim();
|
||||
|
||||
// 정확히 일치하는 경우
|
||||
if (allServices.containsKey(lowerText)) {
|
||||
return allServices[lowerText];
|
||||
}
|
||||
|
||||
// 부분 일치 검색이 활성화된 경우
|
||||
if (usePartialMatch) {
|
||||
// 가장 긴 부분 매칭 찾기
|
||||
String? bestMatch;
|
||||
int maxLength = 0;
|
||||
|
||||
for (var entry in allServices.entries) {
|
||||
final String key = entry.key;
|
||||
|
||||
// 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
// 더 긴 매칭을 우선시
|
||||
if (key.length > maxLength) {
|
||||
maxLength = key.length;
|
||||
bestMatch = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
|
||||
/// 서비스 데이터를 관리하는 저장소 클래스
|
||||
class ServiceDataRepository {
|
||||
@@ -11,12 +12,13 @@ class ServiceDataRepository {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
|
||||
final jsonString =
|
||||
await rootBundle.loadString('assets/data/subscription_services.json');
|
||||
_servicesData = json.decode(jsonString);
|
||||
_isInitialized = true;
|
||||
print('ServiceDataRepository: JSON 데이터 로드 완료');
|
||||
Log.i('ServiceDataRepository: JSON 데이터 로드 완료');
|
||||
} catch (e) {
|
||||
print('ServiceDataRepository: JSON 로드 실패 - $e');
|
||||
Log.w('ServiceDataRepository: JSON 로드 실패 - $e');
|
||||
// 로드 실패시 기존 하드코딩 데이터 사용
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
@@ -25,14 +25,18 @@ class CancellationUrlService {
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
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) {
|
||||
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) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (lowerName.contains(name.toLowerCase()) ||
|
||||
name.toLowerCase().contains(lowerName)) {
|
||||
final cancellationUrls =
|
||||
serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
|
||||
return cancellationUrls[locale] ??
|
||||
@@ -49,14 +53,18 @@ class CancellationUrlService {
|
||||
final domain = _urlMatcher.extractDomain(websiteUrl);
|
||||
if (domain != null) {
|
||||
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) {
|
||||
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) {
|
||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (domain.contains(serviceDomain) ||
|
||||
serviceDomain.contains(domain)) {
|
||||
final cancellationUrls =
|
||||
serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
||||
if (cancellationUrls != null) {
|
||||
return cancellationUrls[locale] ??
|
||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
||||
|
||||
@@ -24,10 +24,12 @@ class CategoryMapperService {
|
||||
final services = categoryData['services'] as Map<String, dynamic>;
|
||||
|
||||
for (final serviceData in services.values) {
|
||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
final names = List<String>.from(
|
||||
(serviceData as Map<String, dynamic>)['names'] ?? []);
|
||||
|
||||
for (final name in names) {
|
||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
||||
if (lowerName.contains(name.toLowerCase()) ||
|
||||
name.toLowerCase().contains(lowerName)) {
|
||||
return getCategoryIdByKey(categoryId);
|
||||
}
|
||||
}
|
||||
@@ -73,15 +75,33 @@ class CategoryMapperService {
|
||||
String getCategoryForLegacyService(String serviceName) {
|
||||
final lowerName = serviceName.toLowerCase();
|
||||
|
||||
if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services';
|
||||
if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming';
|
||||
if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage';
|
||||
if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services';
|
||||
if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools';
|
||||
if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools';
|
||||
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle';
|
||||
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping';
|
||||
if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom';
|
||||
if (LegacyServiceData.ottServices.containsKey(lowerName)) {
|
||||
return 'ott_services';
|
||||
}
|
||||
if (LegacyServiceData.musicServices.containsKey(lowerName)) {
|
||||
return 'music_streaming';
|
||||
}
|
||||
if (LegacyServiceData.storageServices.containsKey(lowerName)) {
|
||||
return 'cloud_storage';
|
||||
}
|
||||
if (LegacyServiceData.aiServices.containsKey(lowerName)) {
|
||||
return 'ai_services';
|
||||
}
|
||||
if (LegacyServiceData.programmingServices.containsKey(lowerName)) {
|
||||
return 'dev_tools';
|
||||
}
|
||||
if (LegacyServiceData.officeTools.containsKey(lowerName)) {
|
||||
return 'office_tools';
|
||||
}
|
||||
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) {
|
||||
return 'lifestyle';
|
||||
}
|
||||
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) {
|
||||
return 'shopping';
|
||||
}
|
||||
if (LegacyServiceData.telecomServices.containsKey(lowerName)) {
|
||||
return 'telecom';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ class ServiceNameResolver {
|
||||
|
||||
// JSON에서 서비스 찾기
|
||||
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) {
|
||||
final data = serviceData as Map<String, dynamic>;
|
||||
|
||||
@@ -36,7 +36,9 @@ class SmsExtractorService {
|
||||
// 모든 서비스명 검사
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
if (lowerSms.contains(entry.key.toLowerCase())) {
|
||||
final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other';
|
||||
final categoryId =
|
||||
await _categoryMapper.findCategoryByServiceName(entry.key) ??
|
||||
'other';
|
||||
|
||||
return ServiceInfo(
|
||||
serviceId: entry.key,
|
||||
|
||||
@@ -2,6 +2,7 @@ import '../models/service_info.dart';
|
||||
import '../data/service_data_repository.dart';
|
||||
import '../data/legacy_service_data.dart';
|
||||
import 'category_mapper_service.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
|
||||
/// URL 매칭 관련 기능을 제공하는 서비스 클래스
|
||||
class UrlMatcherService {
|
||||
@@ -35,7 +36,7 @@ class UrlMatcherService {
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('UrlMatcherService: 도메인 추출 실패 - $e');
|
||||
Log.e('UrlMatcherService: 도메인 추출 실패', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +63,8 @@ class UrlMatcherService {
|
||||
|
||||
// 도메인이 일치하는지 확인
|
||||
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 urls = serviceData['urls'] as Map<String, dynamic>?;
|
||||
|
||||
@@ -106,7 +108,7 @@ class UrlMatcherService {
|
||||
/// 서비스명으로 URL 찾기
|
||||
String? suggestUrl(String serviceName) {
|
||||
if (serviceName.isEmpty) {
|
||||
print('UrlMatcherService: 빈 serviceName');
|
||||
Log.w('UrlMatcherService: 빈 serviceName');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -117,7 +119,7 @@ class UrlMatcherService {
|
||||
// 정확한 매칭을 먼저 시도
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: 정확한 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +127,7 @@ class UrlMatcherService {
|
||||
// OTT 서비스 검사
|
||||
for (final entry in LegacyServiceData.ottServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
@@ -133,7 +135,7 @@ class UrlMatcherService {
|
||||
// 음악 서비스 검사
|
||||
for (final entry in LegacyServiceData.musicServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +143,7 @@ class UrlMatcherService {
|
||||
// AI 서비스 검사
|
||||
for (final entry in LegacyServiceData.aiServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: AI 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
@@ -149,7 +151,7 @@ class UrlMatcherService {
|
||||
// 프로그래밍 서비스 검사
|
||||
for (final entry in LegacyServiceData.programmingServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: 프로그래밍 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
@@ -157,7 +159,7 @@ class UrlMatcherService {
|
||||
// 오피스 툴 검사
|
||||
for (final entry in LegacyServiceData.officeTools.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +167,7 @@ class UrlMatcherService {
|
||||
// 기타 서비스 검사
|
||||
for (final entry in LegacyServiceData.otherServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
@@ -174,15 +176,15 @@ class UrlMatcherService {
|
||||
for (final entry in LegacyServiceData.allServices.entries) {
|
||||
final key = entry.key.toLowerCase();
|
||||
if (key.contains(lowerName) || lowerName.contains(key)) {
|
||||
print('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
|
||||
Log.d('UrlMatcherService: 부분 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
print('UrlMatcherService: 매칭 실패 - $lowerName');
|
||||
Log.d('UrlMatcherService: 매칭 실패 - $lowerName');
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('UrlMatcherService: suggestUrl 에러 - $e');
|
||||
Log.e('UrlMatcherService: suggestUrl 에러', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ class TestSmsData {
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: avoid_print
|
||||
print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}개');
|
||||
return resultData;
|
||||
}
|
||||
@@ -233,7 +234,7 @@ class TestSmsData {
|
||||
];
|
||||
|
||||
// Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제)
|
||||
final microsoftMonthlyCost = 12800.0 / 12;
|
||||
const microsoftMonthlyCost = 12800.0 / 12;
|
||||
|
||||
// 최근 6개월 데이터 생성
|
||||
for (int i = 0; i < 6; i++) {
|
||||
|
||||
@@ -19,24 +19,21 @@ class AdaptiveTheme {
|
||||
secondary: AppColors.secondaryColor,
|
||||
tertiary: AppColors.infoColor,
|
||||
error: AppColors.dangerColor,
|
||||
background: const Color(0xFF121212),
|
||||
surface: const Color(0xFF1E1E1E),
|
||||
surface: Color(0xFF1E1E1E),
|
||||
),
|
||||
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
|
||||
side: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.1), width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
),
|
||||
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
foregroundColor: Colors.white,
|
||||
@@ -53,7 +50,6 @@ class AdaptiveTheme {
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
|
||||
textTheme: TextTheme(
|
||||
headlineLarge: const TextStyle(
|
||||
color: Colors.white,
|
||||
@@ -119,22 +115,24 @@ class AdaptiveTheme {
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2A2A2A),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
|
||||
borderSide:
|
||||
BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
||||
borderSide:
|
||||
const BorderSide(color: AppColors.primaryColor, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -151,7 +149,6 @@ class AdaptiveTheme {
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
@@ -164,7 +161,6 @@ class AdaptiveTheme {
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
|
||||
dividerTheme: DividerThemeData(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
thickness: 1,
|
||||
@@ -178,7 +174,6 @@ class AdaptiveTheme {
|
||||
return darkTheme.copyWith(
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
colorScheme: darkTheme.colorScheme.copyWith(
|
||||
background: Colors.black,
|
||||
surface: const Color(0xFF0A0A0A),
|
||||
),
|
||||
cardTheme: darkTheme.cardTheme.copyWith(
|
||||
@@ -203,10 +198,8 @@ class AdaptiveTheme {
|
||||
secondary: Colors.black87,
|
||||
tertiary: Colors.black54,
|
||||
error: Colors.red,
|
||||
background: Colors.white,
|
||||
surface: Colors.white,
|
||||
),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(
|
||||
color: Colors.black,
|
||||
@@ -234,7 +227,6 @@ class AdaptiveTheme {
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -242,7 +234,6 @@ class AdaptiveTheme {
|
||||
side: const BorderSide(color: Colors.black, width: 2),
|
||||
),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -263,20 +254,17 @@ class AdaptiveTheme {
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: brightness == Brightness.dark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
statusBarBrightness: brightness == Brightness.dark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
statusBarIconBrightness:
|
||||
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||
statusBarBrightness:
|
||||
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isOled
|
||||
? Colors.black
|
||||
: (brightness == Brightness.dark
|
||||
? const Color(0xFF121212)
|
||||
: Colors.white),
|
||||
systemNavigationBarIconBrightness: brightness == Brightness.dark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
systemNavigationBarIconBrightness:
|
||||
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ class AppTheme {
|
||||
secondary: AppColors.secondaryColor,
|
||||
tertiary: AppColors.infoColor,
|
||||
error: AppColors.dangerColor,
|
||||
background: AppColors.backgroundColor,
|
||||
surface: AppColors.surfaceColor,
|
||||
),
|
||||
|
||||
@@ -36,13 +35,13 @@ class AppTheme {
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: const TextStyle(
|
||||
titleTextStyle: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
iconTheme: const IconThemeData(
|
||||
iconTheme: IconThemeData(
|
||||
color: AppColors.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
@@ -51,21 +50,21 @@ class AppTheme {
|
||||
// 타이포그래피 - Metronic Tailwind 스타일
|
||||
textTheme: const TextTheme(
|
||||
// 헤드라인 - 페이지 제목
|
||||
headlineLarge: const TextStyle(
|
||||
headlineLarge: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
),
|
||||
headlineMedium: const TextStyle(
|
||||
headlineMedium: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
),
|
||||
headlineSmall: const TextStyle(
|
||||
headlineSmall: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -74,7 +73,7 @@ class AppTheme {
|
||||
),
|
||||
|
||||
// 타이틀 - 카드, 섹션 제목
|
||||
titleLarge: const TextStyle(
|
||||
titleLarge: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -257,14 +256,14 @@ class AppTheme {
|
||||
|
||||
// 스위치 스타일
|
||||
switchTheme: SwitchThemeData(
|
||||
thumbColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
thumbColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return Colors.white;
|
||||
}),
|
||||
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
trackColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.secondaryColor.withValues(alpha: 0.5);
|
||||
}
|
||||
return AppColors.borderColor;
|
||||
@@ -273,8 +272,8 @@ class AppTheme {
|
||||
|
||||
// 체크박스 스타일
|
||||
checkboxTheme: CheckboxThemeData(
|
||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return Colors.transparent;
|
||||
@@ -287,8 +286,8 @@ class AppTheme {
|
||||
|
||||
// 라디오 버튼 스타일
|
||||
radioTheme: RadioThemeData(
|
||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return AppColors.textSecondary;
|
||||
@@ -311,12 +310,12 @@ class AppTheme {
|
||||
labelColor: AppColors.primaryColor,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.primaryColor,
|
||||
labelStyle: const TextStyle(
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
unselectedLabelStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 숫자와 날짜를 포맷팅하는 유틸리티 클래스
|
||||
class FormatHelper {
|
||||
/// 통화 형식으로 숫자 포맷팅
|
||||
static String formatCurrency(double value) {
|
||||
return NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(value);
|
||||
}
|
||||
|
||||
/// 날짜를 yyyy년 MM월 dd일 형식으로 포맷팅
|
||||
static String formatDate(DateTime date) {
|
||||
return '${date.year}년 ${date.month}월 ${date.day}일';
|
||||
}
|
||||
|
||||
/// 날짜를 MM.dd 형식으로 포맷팅 (짧은 형식)
|
||||
static String formatShortDate(DateTime date) {
|
||||
return '${date.month}.${date.day}';
|
||||
}
|
||||
|
||||
/// 현재 날짜로부터 남은 일 수 계산
|
||||
static String getRemainingDays(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = date.difference(now).inDays;
|
||||
|
||||
if (difference < 0) {
|
||||
return '${-difference}일 지남';
|
||||
} else if (difference == 0) {
|
||||
return '오늘';
|
||||
} else {
|
||||
return '$difference일 후';
|
||||
}
|
||||
}
|
||||
}
|
||||
27
lib/utils/logger.dart
Normal file
27
lib/utils/logger.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'logger.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// 메모리 관리를 위한 헬퍼 클래스
|
||||
@@ -57,15 +58,14 @@ class MemoryManager {
|
||||
void clearCache() {
|
||||
_cache.clear();
|
||||
if (kDebugMode) {
|
||||
print('🧹 메모리 캐시가 비워졌습니다.');
|
||||
Log.d('🧹 메모리 캐시가 비워졌습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/// 특정 패턴의 캐시 제거
|
||||
void clearCacheByPattern(String pattern) {
|
||||
final keysToRemove = _cache.keys
|
||||
.where((key) => key.contains(pattern))
|
||||
.toList();
|
||||
final keysToRemove =
|
||||
_cache.keys.where((key) => key.contains(pattern)).toList();
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
_cache.remove(key);
|
||||
@@ -123,7 +123,7 @@ class MemoryManager {
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
if (kDebugMode) {
|
||||
print('🖼️ 이미지 캐시가 비워졌습니다.');
|
||||
Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,7 @@ class MemoryManager {
|
||||
|
||||
/// 살아있는 위젯 수 확인
|
||||
int getAliveWidgetCount() {
|
||||
return _widgetReferences.values
|
||||
.where((ref) => ref.target != null)
|
||||
.length;
|
||||
return _widgetReferences.values.where((ref) => ref.target != null).length;
|
||||
}
|
||||
|
||||
/// 메모리 압박 시 대응
|
||||
@@ -158,7 +156,7 @@ class MemoryManager {
|
||||
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('⚠️ 메모리 압박 대응: 캐시 크기 감소');
|
||||
Log.w('메모리 압박 대응: 캐시 크기 감소');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +229,8 @@ class ImageCacheStatus {
|
||||
});
|
||||
|
||||
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
|
||||
double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100;
|
||||
double get bytesUsagePercentage =>
|
||||
(currentSizeBytes / maximumSizeBytes) * 100;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'currentSize': currentSize,
|
||||
@@ -263,10 +262,8 @@ class MemoryEfficientListView<T> extends StatefulWidget {
|
||||
_MemoryEfficientListViewState<T>();
|
||||
}
|
||||
|
||||
class _MemoryEfficientListViewState<T>
|
||||
extends State<MemoryEfficientListView<T>>
|
||||
class _MemoryEfficientListViewState<T> extends State<MemoryEfficientListView<T>>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => false;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// 성능 최적화를 위한 유틸리티 클래스
|
||||
class PerformanceOptimizer {
|
||||
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal();
|
||||
static final PerformanceOptimizer _instance =
|
||||
PerformanceOptimizer._internal();
|
||||
factory PerformanceOptimizer() => _instance;
|
||||
PerformanceOptimizer._internal();
|
||||
|
||||
@@ -104,7 +106,8 @@ class PerformanceOptimizer {
|
||||
}
|
||||
|
||||
/// 이미지 최적화 - 메모리 효율적인 크기로 조정
|
||||
static double getOptimalImageSize(BuildContext context, {
|
||||
static double getOptimalImageSize(
|
||||
BuildContext context, {
|
||||
required double originalSize,
|
||||
double maxSize = 1000,
|
||||
}) {
|
||||
@@ -139,12 +142,12 @@ class PerformanceOptimizer {
|
||||
/// 빌드 최적화를 위한 const 위젯 권장사항 체크
|
||||
static void checkConstOptimization() {
|
||||
if (kDebugMode) {
|
||||
print('💡 성능 최적화 팁:');
|
||||
print('1. 가능한 모든 위젯에 const 사용');
|
||||
print('2. StatelessWidget 대신 const 생성자 사용');
|
||||
print('3. 큰 리스트는 ListView.builder 사용');
|
||||
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드');
|
||||
print('5. 애니메이션은 AnimatedBuilder 사용');
|
||||
Log.i('💡 성능 최적화 팁:\n'
|
||||
'1. 가능한 모든 위젯에 const 사용\n'
|
||||
'2. StatelessWidget 대신 const 생성자 사용\n'
|
||||
'3. 큰 리스트는 ListView.builder 사용\n'
|
||||
'4. 이미지는 캐싱과 함께 적절한 크기로 로드\n'
|
||||
'5. 애니메이션은 AnimatedBuilder 사용');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,12 +157,12 @@ class PerformanceOptimizer {
|
||||
static void trackWidget(String widgetName, bool isCreated) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
_widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) +
|
||||
(isCreated ? 1 : -1);
|
||||
_widgetCounts[widgetName] =
|
||||
(_widgetCounts[widgetName] ?? 0) + (isCreated ? 1 : -1);
|
||||
|
||||
// 위젯이 비정상적으로 많이 생성되면 경고
|
||||
if ((_widgetCounts[widgetName] ?? 0) > 100) {
|
||||
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
|
||||
Log.w('경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,8 +179,10 @@ class MemoryInfo {
|
||||
|
||||
double get usagePercentage => (currentUsage / capacity) * 100;
|
||||
|
||||
String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||
String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||
String get formattedUsage =>
|
||||
'${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||
String get formattedCapacity =>
|
||||
'${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
/// 성능 측정 데코레이터
|
||||
@@ -192,11 +197,11 @@ class PerformanceMeasure {
|
||||
try {
|
||||
final result = await operation();
|
||||
stopwatch.stop();
|
||||
print('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms');
|
||||
Log.d('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms');
|
||||
return result;
|
||||
} catch (e) {
|
||||
stopwatch.stop();
|
||||
print('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms - $e');
|
||||
Log.e('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
34
lib/utils/reduce_motion.dart
Normal file
34
lib/utils/reduce_motion.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 접근성 설정에 따른 모션 축소 여부 헬퍼
|
||||
class ReduceMotion {
|
||||
/// 플랫폼 접근성 설정을 기반으로 모션 축소 여부 반환 (context 없이 사용)
|
||||
static bool platform() {
|
||||
final features =
|
||||
WidgetsBinding.instance.platformDispatcher.accessibilityFeatures;
|
||||
// disableAnimations 신뢰
|
||||
return features.disableAnimations;
|
||||
}
|
||||
|
||||
/// MediaQuery/플랫폼 정보를 활용해 런타임에서 모션 축소 여부 반환
|
||||
static bool isEnabled(BuildContext context) {
|
||||
final mq = MediaQuery.maybeOf(context);
|
||||
if (mq != null) {
|
||||
// accessibleNavigation == 사용자가 단순한 네비게이션/애니메이션 선호
|
||||
if (mq.accessibleNavigation) return true;
|
||||
}
|
||||
return platform();
|
||||
}
|
||||
|
||||
/// 모션 강도 스케일 유틸리티
|
||||
static double scale(BuildContext context,
|
||||
{required double normal, required double reduced}) {
|
||||
return isEnabled(context) ? reduced : normal;
|
||||
}
|
||||
|
||||
/// 파티클 개수 등 정수 스케일링
|
||||
static int count(BuildContext context,
|
||||
{required int normal, required int reduced}) {
|
||||
return isEnabled(context) ? reduced : normal;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../services/url_matcher/data/legacy_service_data.dart';
|
||||
|
||||
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스
|
||||
@@ -86,8 +85,8 @@ class SubscriptionCategoryHelper {
|
||||
categorizedSubscriptions['shoppingEcommerce']!.add(subscription);
|
||||
}
|
||||
// 프로그래밍
|
||||
else if (_isInCategory(subscription.serviceName,
|
||||
LegacyServiceData.programmingServices)) {
|
||||
else if (_isInCategory(
|
||||
subscription.serviceName, LegacyServiceData.programmingServices)) {
|
||||
if (!categorizedSubscriptions.containsKey('programming')) {
|
||||
categorizedSubscriptions['programming'] = [];
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import '../../controllers/add_subscription_controller.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독 추가 화면의 App Bar
|
||||
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
class AddSubscriptionAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
final AddSubscriptionController controller;
|
||||
final double scrollOffset;
|
||||
final VoidCallback onScanSMS;
|
||||
|
||||
@@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart';
|
||||
import '../common/form_fields/currency_input_field.dart';
|
||||
import '../common/form_fields/date_picker_field.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 구독 추가 화면의 이벤트/할인 섹션
|
||||
class AddSubscriptionEventSection extends StatelessWidget {
|
||||
@@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -66,7 +65,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: controller.gradientColors[0].withValues(alpha: 0.1),
|
||||
color: controller.gradientColors[0]
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -146,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: AppColors.infoColor,
|
||||
size: 20,
|
||||
@@ -155,7 +155,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final locale = Localizations.localeOf(context);
|
||||
final locale =
|
||||
Localizations.localeOf(context);
|
||||
String infoText;
|
||||
switch (locale.languageCode) {
|
||||
case 'ko':
|
||||
@@ -168,11 +169,12 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
infoText = '设置折扣或促销价格';
|
||||
break;
|
||||
default:
|
||||
infoText = 'Set up discount or promotion price';
|
||||
infoText =
|
||||
'Set up discount or promotion price';
|
||||
}
|
||||
return Text(
|
||||
infoText,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -216,8 +218,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
setState(() {
|
||||
controller.eventStartDate = date;
|
||||
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||
if (date != null && controller.eventEndDate == null) {
|
||||
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||
if (date != null &&
|
||||
controller.eventEndDate == null) {
|
||||
controller.eventEndDate =
|
||||
date.add(const Duration(days: 30));
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -238,7 +242,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
Builder(
|
||||
builder: (BuildContext innerContext) {
|
||||
// 현재 로케일 확인
|
||||
final currentLocale = Localizations.localeOf(innerContext);
|
||||
final currentLocale =
|
||||
Localizations.localeOf(innerContext);
|
||||
|
||||
// 로케일에 따라 직접 텍스트 설정
|
||||
String eventPriceLabel;
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../../models/subscription_model.dart';
|
||||
import '../../services/currency_util.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
||||
class AnalysisBadge extends StatelessWidget {
|
||||
@@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
|
||||
color: borderColor,
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
@@ -69,13 +68,17 @@ class AnalysisBadge extends StatelessWidget {
|
||||
String displayText = amountText;
|
||||
if (amountText.length > 12) {
|
||||
// 괄호 안의 내용 제거
|
||||
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
|
||||
displayText =
|
||||
amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
|
||||
}
|
||||
if (displayText.length > 10) {
|
||||
// 통화 기호만 남기고 숫자만 표시
|
||||
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency);
|
||||
displayText = displayText.replaceAll(currencySymbol, '').trim();
|
||||
displayText = '$currencySymbol${displayText.substring(0, 6)}...';
|
||||
final currencySymbol =
|
||||
CurrencyUtil.getCurrencySymbol(subscription.currency);
|
||||
displayText =
|
||||
displayText.replaceAll(currencySymbol, '').trim();
|
||||
displayText =
|
||||
'$currencySymbol${displayText.substring(0, 6)}...';
|
||||
}
|
||||
return Text(
|
||||
displayText,
|
||||
|
||||
@@ -48,10 +48,12 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: AppLocalizations.of(context).eventDiscountStatus,
|
||||
text: AppLocalizations.of(context)
|
||||
.eventDiscountStatus,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
@@ -79,7 +81,10 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context).servicesInProgress(provider.activeEventSubscriptions.length),
|
||||
AppLocalizations.of(context)
|
||||
.servicesInProgress(provider
|
||||
.activeEventSubscriptions
|
||||
.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -97,15 +102,18 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
||||
const Color(0xFFFF8787).withValues(alpha: 0.1),
|
||||
const Color(0xFFFF6B6B)
|
||||
.withValues(alpha: 0.1),
|
||||
const Color(0xFFFF8787)
|
||||
.withValues(alpha: 0.1),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3),
|
||||
color: const Color(0xFFFF6B6B)
|
||||
.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -118,10 +126,12 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).monthlySavingAmount,
|
||||
AppLocalizations.of(context)
|
||||
.monthlySavingAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -154,24 +164,29 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...provider.activeEventSubscriptions.map((sub) {
|
||||
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice);
|
||||
final savings = sub.originalPrice -
|
||||
(sub.eventPrice ?? sub.originalPrice);
|
||||
final discountRate =
|
||||
((savings / sub.originalPrice) * 100).round();
|
||||
((savings / sub.originalPrice) * 100)
|
||||
.round();
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkNavy.withValues(alpha: 0.05),
|
||||
color: AppColors.darkNavy
|
||||
.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.darkNavy.withValues(alpha: 0.1),
|
||||
color: AppColors.darkNavy
|
||||
.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText(
|
||||
sub.serviceName,
|
||||
@@ -184,8 +199,8 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil
|
||||
.formatAmount(
|
||||
future:
|
||||
CurrencyUtil.formatAmount(
|
||||
sub.originalPrice,
|
||||
sub.currency),
|
||||
builder: (context, snapshot) {
|
||||
@@ -194,9 +209,11 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration
|
||||
decoration:
|
||||
TextDecoration
|
||||
.lineThrough,
|
||||
color: AppColors.navyGray,
|
||||
color: AppColors
|
||||
.navyGray,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -211,9 +228,10 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil
|
||||
.formatAmount(
|
||||
sub.eventPrice ?? sub.originalPrice,
|
||||
future:
|
||||
CurrencyUtil.formatAmount(
|
||||
sub.eventPrice ??
|
||||
sub.originalPrice,
|
||||
sub.currency),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
@@ -244,7 +262,8 @@ class EventAnalysisCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B6B)
|
||||
.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'$discountRate${AppLocalizations.of(context).discountPercent}',
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../theme/app_colors.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/reduce_motion.dart';
|
||||
|
||||
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
||||
class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
@@ -35,7 +36,8 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
if (maxValue <= 1000000) return 1000000;
|
||||
|
||||
// 큰 금액은 자릿수에 맞춰 반올림
|
||||
final magnitude = math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
|
||||
final magnitude =
|
||||
math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
|
||||
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
|
||||
} else {
|
||||
// 소수점이 있는 통화 (달러, 위안)
|
||||
@@ -153,8 +155,9 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// 바 차트
|
||||
AspectRatio(
|
||||
// 바 차트 (RepaintBoundary로 페인트 분리)
|
||||
RepaintBoundary(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.6,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
@@ -164,8 +167,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
0,
|
||||
(max, data) => math.max(
|
||||
max, data['totalExpense'] as double)),
|
||||
locale
|
||||
),
|
||||
locale),
|
||||
barGroups: _getMonthlyBarGroups(locale),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
@@ -176,13 +178,12 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
0,
|
||||
(max, data) => math.max(max,
|
||||
data['totalExpense'] as double)),
|
||||
locale
|
||||
),
|
||||
CurrencyUtil.getDefaultCurrency(locale)
|
||||
),
|
||||
locale),
|
||||
CurrencyUtil.getDefaultCurrency(locale)),
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: AppColors.navyGray.withValues(alpha: 0.1),
|
||||
color:
|
||||
AppColors.navyGray.withValues(alpha: 0.1),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
@@ -233,9 +234,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: CurrencyUtil.formatTotalAmountWithLocale(
|
||||
monthlyData[group.x]['totalExpense']
|
||||
as double,
|
||||
text: CurrencyUtil
|
||||
.formatTotalAmountWithLocale(
|
||||
monthlyData[group.x]
|
||||
['totalExpense'] as double,
|
||||
locale),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFBBF24),
|
||||
@@ -249,12 +251,18 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
swapAnimationDuration: ReduceMotion.isEnabled(context)
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
swapAnimationCurve: Curves.easeOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: ThemedText.caption(
|
||||
text: AppLocalizations.of(context).monthlySubscriptionExpense,
|
||||
text: AppLocalizations.of(context)
|
||||
.monthlySubscriptionExpense,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../themed_text.dart';
|
||||
import 'analysis_badge.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
import '../../utils/reduce_motion.dart';
|
||||
|
||||
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
||||
class SubscriptionPieChartCard extends StatefulWidget {
|
||||
@@ -23,7 +24,8 @@ class SubscriptionPieChartCard extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState();
|
||||
State<SubscriptionPieChartCard> createState() =>
|
||||
_SubscriptionPieChartCardState();
|
||||
}
|
||||
|
||||
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
@@ -78,7 +80,6 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
|
||||
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
|
||||
Future<List<PieChartSectionData>> _getPieSections() async {
|
||||
|
||||
if (widget.subscriptions.isEmpty) return [];
|
||||
|
||||
// 현재 locale 가져오기
|
||||
@@ -97,11 +98,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
sectionValues.add(value);
|
||||
} else if (subscription.currency == 'USD') {
|
||||
// USD를 기본 통화로 변환
|
||||
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency);
|
||||
final converted = await ExchangeRateService()
|
||||
.convertUsdToTarget(value, defaultCurrency);
|
||||
sectionValues.add(converted ?? value);
|
||||
} else if (defaultCurrency == 'USD') {
|
||||
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
|
||||
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency);
|
||||
final converted = await ExchangeRateService()
|
||||
.convertTargetToUsd(value, subscription.currency);
|
||||
sectionValues.add(converted ?? value);
|
||||
} else {
|
||||
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
|
||||
@@ -159,7 +162,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
}
|
||||
|
||||
// 터치 상태를 반영한 섹션 데이터 생성
|
||||
List<PieChartSectionData> _applyTouchedState(List<PieChartSectionData> sections) {
|
||||
List<PieChartSectionData> _applyTouchedState(
|
||||
List<PieChartSectionData> sections) {
|
||||
return List.generate(sections.length, (i) {
|
||||
final section = sections[i];
|
||||
final isTouched = _touchedIndex == i;
|
||||
@@ -169,12 +173,14 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
return PieChartSectionData(
|
||||
value: section.value,
|
||||
title: section.title,
|
||||
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle(
|
||||
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ??
|
||||
TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.pureWhite,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||
Shadow(
|
||||
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||
],
|
||||
),
|
||||
color: section.color,
|
||||
@@ -217,18 +223,20 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: AppLocalizations.of(context).subscriptionServiceRatio,
|
||||
text: AppLocalizations.of(context)
|
||||
.subscriptionServiceRatio,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfoForLocale(
|
||||
context.watch<LocaleProvider>().locale.languageCode
|
||||
),
|
||||
context
|
||||
.watch<LocaleProvider>()
|
||||
.locale
|
||||
.languageCode),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData &&
|
||||
snapshot.data!.isNotEmpty) {
|
||||
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
@@ -236,15 +244,15 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F2FF),
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFBFDBFE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).exchangeRateFormat(snapshot.data!),
|
||||
AppLocalizations.of(context)
|
||||
.exchangeRateFormat(snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -272,7 +280,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
height: 250,
|
||||
child: Center(
|
||||
child: ThemedText(
|
||||
AppLocalizations.of(context).noSubscriptionServices,
|
||||
AppLocalizations.of(context)
|
||||
.noSubscriptionServices,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
@@ -284,16 +293,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
child: FutureBuilder<List<PieChartSectionData>>(
|
||||
future: _pieSectionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
if (!snapshot.hasData ||
|
||||
snapshot.data!.isEmpty) {
|
||||
return Center(
|
||||
child: ThemedText(
|
||||
AppLocalizations.of(context).noSubscriptionServices,
|
||||
AppLocalizations.of(context)
|
||||
.noSubscriptionServices,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
@@ -301,19 +313,23 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
);
|
||||
}
|
||||
|
||||
return PieChart(
|
||||
return RepaintBoundary(
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
borderData: FlBorderData(show: false),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 60,
|
||||
sections: _applyTouchedState(snapshot.data!),
|
||||
sections:
|
||||
_applyTouchedState(snapshot.data!),
|
||||
pieTouchData: PieTouchData(
|
||||
enabled: true,
|
||||
touchCallback: (FlTouchEvent event,
|
||||
pieTouchResponse) {
|
||||
// 터치 응답이 없거나 섹션이 없는 경우
|
||||
if (pieTouchResponse == null ||
|
||||
pieTouchResponse.touchedSection == null) {
|
||||
pieTouchResponse
|
||||
.touchedSection ==
|
||||
null) {
|
||||
// 차트 밖으로 나갔을 때만 리셋
|
||||
if (_touchedIndex != -1) {
|
||||
setState(() {
|
||||
@@ -323,15 +339,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
return;
|
||||
}
|
||||
|
||||
final touchedIndex = pieTouchResponse
|
||||
.touchedSection!
|
||||
final touchedIndex =
|
||||
pieTouchResponse.touchedSection!
|
||||
.touchedSectionIndex;
|
||||
|
||||
// 탭 이벤트 처리 (토글)
|
||||
if (event is FlTapUpEvent) {
|
||||
setState(() {
|
||||
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
||||
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
|
||||
_touchedIndex =
|
||||
(_touchedIndex ==
|
||||
touchedIndex)
|
||||
? -1
|
||||
: touchedIndex;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -340,7 +360,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
if (event is FlPointerHoverEvent ||
|
||||
event is FlPointerEnterEvent) {
|
||||
// 현재 인덱스와 다른 경우만 업데이트
|
||||
if (_touchedIndex != touchedIndex) {
|
||||
if (_touchedIndex !=
|
||||
touchedIndex) {
|
||||
setState(() {
|
||||
_touchedIndex = touchedIndex;
|
||||
});
|
||||
@@ -349,6 +370,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
},
|
||||
),
|
||||
),
|
||||
swapAnimationDuration:
|
||||
ReduceMotion.isEnabled(context)
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(
|
||||
milliseconds: 300),
|
||||
swapAnimationCurve: Curves.easeOut,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -364,10 +392,10 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
(index) {
|
||||
final subscription =
|
||||
widget.subscriptions[index];
|
||||
final color = _chartColors[index % _chartColors.length];
|
||||
final color =
|
||||
_chartColors[index % _chartColors.length];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 4.0),
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -385,31 +413,31 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil
|
||||
.formatSubscriptionAmountWithLocale(
|
||||
subscription,
|
||||
context.read<LocaleProvider>().locale.languageCode),
|
||||
context
|
||||
.read<LocaleProvider>()
|
||||
.locale
|
||||
.languageCode),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ThemedText(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
parent: animationController,
|
||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||
)),
|
||||
child: RepaintBoundary(
|
||||
child: GlassmorphismCard(
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
@@ -56,7 +57,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text: AppLocalizations.of(context).totalExpenseSummary,
|
||||
text: AppLocalizations.of(context)
|
||||
.totalExpenseSummary,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
@@ -67,20 +69,24 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
final totalExpenseText = CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale);
|
||||
final totalExpenseText =
|
||||
CurrencyUtil.formatTotalAmountWithLocale(
|
||||
totalExpense, locale);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: totalExpenseText));
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).totalExpenseCopied(totalExpenseText)),
|
||||
content: Text(AppLocalizations.of(context)
|
||||
.totalExpenseCopied(totalExpenseText)),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||
backgroundColor: AppColors.glassBackground
|
||||
.withValues(alpha: 0.3),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
@@ -107,7 +113,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: AppLocalizations.of(context).totalExpense,
|
||||
text:
|
||||
AppLocalizations.of(context).totalExpense,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -115,7 +122,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ThemedText(
|
||||
CurrencyUtil.formatTotalAmountWithLocale(totalExpense, locale),
|
||||
CurrencyUtil.formatTotalAmountWithLocale(
|
||||
totalExpense, locale),
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -134,10 +142,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||
color: AppColors.glassBackground
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
||||
color: AppColors.glassBorder
|
||||
.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: const FaIcon(
|
||||
@@ -152,7 +162,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: AppLocalizations.of(context).totalServices,
|
||||
text: AppLocalizations.of(context)
|
||||
.totalServices,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -160,7 +171,9 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ThemedText(
|
||||
AppLocalizations.of(context).subscriptionCount(subscriptions.length),
|
||||
AppLocalizations.of(context)
|
||||
.subscriptionCount(
|
||||
subscriptions.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -176,10 +189,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||
color: AppColors.glassBackground
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
||||
color: AppColors.glassBorder
|
||||
.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: const FaIcon(
|
||||
@@ -194,7 +209,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: AppLocalizations.of(context).averageCost,
|
||||
text: AppLocalizations.of(context)
|
||||
.averageCost,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -202,10 +218,12 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ThemedText(
|
||||
CurrencyUtil.formatTotalAmountWithLocale(
|
||||
CurrencyUtil
|
||||
.formatTotalAmountWithLocale(
|
||||
subscriptions.isEmpty
|
||||
? 0
|
||||
: totalExpense / subscriptions.length,
|
||||
: totalExpense /
|
||||
subscriptions.length,
|
||||
locale),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
@@ -228,6 +246,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
/// 슬라이드 + 페이드 전환
|
||||
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||
@@ -11,8 +12,12 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.direction = AxisDirection.right,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
Offset begin;
|
||||
switch (direction) {
|
||||
@@ -64,8 +69,12 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.alignment = Alignment.center,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 400),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 400),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 400),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 400),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const curve = Curves.elasticOut;
|
||||
|
||||
@@ -98,8 +107,12 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
||||
RotatePageRoute({required this.page})
|
||||
: super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const curve = Curves.easeInOut;
|
||||
|
||||
@@ -135,10 +148,15 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.horizontal = true,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 800),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 800),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 800),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 800),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final isAnimatingForward = animation.status == AnimationStatus.forward;
|
||||
final isAnimatingForward =
|
||||
animation.status == AnimationStatus.forward;
|
||||
|
||||
final flipAnimation = Tween(
|
||||
begin: 0.0,
|
||||
@@ -188,8 +206,12 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.borderRadius,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -259,8 +281,12 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
||||
required this.transitionType,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
late final Offset begin;
|
||||
late final Offset end;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
||||
///
|
||||
@@ -16,6 +17,8 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reduce = ReduceMotion.isEnabled(context);
|
||||
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
|
||||
return Stack(
|
||||
children: [
|
||||
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
||||
@@ -25,15 +28,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
||||
final angle = controller.value * 2 * math.pi;
|
||||
// 사인 함수를 사용하여 부드러운 움직임 생성
|
||||
final xOffset = 20 * math.sin(angle);
|
||||
final yOffset = 10 * math.cos(angle);
|
||||
final xOffset = 20 * amp * math.sin(angle);
|
||||
final yOffset = 10 * amp * math.cos(angle);
|
||||
|
||||
return Positioned(
|
||||
right: -40 + xOffset,
|
||||
top: -60 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 회전도 선형적으로 변화하도록 수정
|
||||
angle: 0.2 * math.sin(angle * 0.5),
|
||||
angle: 0.2 * amp * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
@@ -51,15 +54,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
builder: (context, child) {
|
||||
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
||||
final xOffset = 20 * math.cos(angle);
|
||||
final yOffset = 10 * math.sin(angle);
|
||||
final xOffset = 20 * amp * math.cos(angle);
|
||||
final yOffset = 10 * amp * math.sin(angle);
|
||||
|
||||
return Positioned(
|
||||
left: -80 + xOffset,
|
||||
bottom: -70 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 반대 방향으로 회전하도록 설정
|
||||
angle: -0.3 * math.sin(angle * 0.5),
|
||||
angle: -0.3 * amp * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
@@ -78,14 +81,14 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
builder: (context, child) {
|
||||
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
||||
final xOffset = 15 * math.sin(angle * 0.7);
|
||||
final yOffset = 8 * math.cos(angle * 0.7);
|
||||
final xOffset = 15 * amp * math.sin(angle * 0.7);
|
||||
final yOffset = 8 * amp * math.cos(angle * 0.7);
|
||||
|
||||
return Positioned(
|
||||
right: 40 + xOffset,
|
||||
bottom: -40 + yOffset,
|
||||
child: Transform.rotate(
|
||||
angle: 0.4 * math.cos(angle * 0.5),
|
||||
angle: 0.4 * amp * math.cos(angle * 0.5),
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
@@ -109,9 +112,8 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha:
|
||||
0.1 + 0.1 * pulseController.value,
|
||||
),
|
||||
color: Colors.white.withValues(
|
||||
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'animated_page_transitions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
@@ -42,8 +43,9 @@ class AppNavigator {
|
||||
}
|
||||
|
||||
/// 구독 상세 화면으로 네비게이션
|
||||
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
|
||||
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
|
||||
static Future<void> toDetail(
|
||||
BuildContext context, SubscriptionModel subscription) async {
|
||||
Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
try {
|
||||
@@ -51,9 +53,9 @@ class AppNavigator {
|
||||
AppRoutes.subscriptionDetail,
|
||||
arguments: subscription,
|
||||
);
|
||||
print('DetailScreen 네비게이션 성공');
|
||||
Log.d('DetailScreen 네비게이션 성공');
|
||||
} catch (e) {
|
||||
print('DetailScreen 네비게이션 오류: $e');
|
||||
Log.e('DetailScreen 네비게이션 오류', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +198,7 @@ class AppNavigationObserver extends NavigatorObserver {
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? 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}');
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,8 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
final parts = <String>[];
|
||||
|
||||
// 개수는 항상 표시
|
||||
parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
|
||||
parts
|
||||
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
|
||||
|
||||
// 통화 부분을 별도로 처리
|
||||
final currencyParts = <String>[];
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// 위험한 액션에 사용되는 Danger 버튼
|
||||
/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다.
|
||||
class DangerButton extends StatefulWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool requireConfirmation;
|
||||
final String? confirmationTitle;
|
||||
final String? confirmationMessage;
|
||||
final IconData? icon;
|
||||
final double? width;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double borderRadius;
|
||||
final bool enableHoverEffect;
|
||||
|
||||
const DangerButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.requireConfirmation = false,
|
||||
this.confirmationTitle,
|
||||
this.confirmationMessage,
|
||||
this.icon,
|
||||
this.width,
|
||||
this.height = 60,
|
||||
this.fontSize = 18,
|
||||
this.padding,
|
||||
this.borderRadius = 16,
|
||||
this.enableHoverEffect = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DangerButton> createState() => _DangerButtonState();
|
||||
}
|
||||
|
||||
class _DangerButtonState extends State<DangerButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
static const Color _dangerColor = AppColors.dangerColor;
|
||||
|
||||
Future<void> _handlePress() async {
|
||||
if (widget.requireConfirmation) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
title: Text(
|
||||
widget.confirmationTitle ?? widget.text,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _dangerColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon ?? Icons.warning_amber_rounded,
|
||||
color: _dangerColor,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.confirmationMessage ??
|
||||
'이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _dangerColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.text,
|
||||
style: const TextStyle(color: AppColors.pureWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
} else {
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget button = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: widget.width ?? double.infinity,
|
||||
height: widget.height,
|
||||
transform: widget.enableHoverEffect && _isHovered
|
||||
? (Matrix4.identity()..scale(1.02))
|
||||
: Matrix4.identity(),
|
||||
child: ElevatedButton(
|
||||
onPressed: widget.onPressed != null ? _handlePress : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _dangerColor,
|
||||
foregroundColor: AppColors.pureWhite,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
),
|
||||
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||
disabledBackgroundColor: _dangerColor.withValues(alpha: 0.6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.icon != null) ...[
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: AppColors.pureWhite,
|
||||
size: _isHovered ? 24 : 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
widget.text,
|
||||
style: TextStyle(
|
||||
fontSize: widget.fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.pureWhite,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.enableHoverEffect) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,10 @@ class _PrimaryButtonState extends State<PrimaryButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor;
|
||||
final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite;
|
||||
final effectiveBackgroundColor =
|
||||
widget.backgroundColor ?? theme.primaryColor;
|
||||
final effectiveForegroundColor =
|
||||
widget.foregroundColor ?? AppColors.pureWhite;
|
||||
|
||||
Widget button = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -64,7 +66,8 @@ class _PrimaryButtonState extends State<PrimaryButton> {
|
||||
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||
disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6),
|
||||
disabledBackgroundColor:
|
||||
effectiveBackgroundColor.withValues(alpha: 0.6),
|
||||
),
|
||||
child: widget.isLoading
|
||||
? SizedBox(
|
||||
|
||||
@@ -42,7 +42,6 @@ class _SecondaryButtonState extends State<SecondaryButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
|
||||
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
|
||||
|
||||
@@ -66,13 +65,13 @@ class _SecondaryButtonState extends State<SecondaryButton> {
|
||||
: effectiveBorderColor,
|
||||
width: widget.borderWidth,
|
||||
),
|
||||
padding: widget.padding ?? const EdgeInsets.symmetric(
|
||||
padding: widget.padding ??
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 24,
|
||||
),
|
||||
backgroundColor: _isHovered
|
||||
? AppColors.glassBackground
|
||||
: Colors.transparent,
|
||||
backgroundColor:
|
||||
_isHovered ? AppColors.glassBackground : Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -179,9 +178,8 @@ class _TextLinkButtonState extends State<TextLinkButton> {
|
||||
fontSize: widget.fontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: effectiveColor,
|
||||
decoration: _isHovered
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
decoration:
|
||||
_isHovered ? TextDecoration.underline : TextDecoration.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 섹션별 컨텐츠를 감싸는 기본 카드 위젯
|
||||
/// 폼 섹션, 정보 표시 섹션 등에 사용됩니다.
|
||||
class SectionCard extends StatelessWidget {
|
||||
final String? title;
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color? backgroundColor;
|
||||
final double borderRadius;
|
||||
final List<BoxShadow>? boxShadow;
|
||||
final Border? border;
|
||||
final double? height;
|
||||
final double? width;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SectionCard({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.backgroundColor,
|
||||
this.borderRadius = 20,
|
||||
this.boxShadow,
|
||||
this.border,
|
||||
this.height,
|
||||
this.width,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectiveBackgroundColor = backgroundColor ?? Colors.white;
|
||||
final effectiveShadow = boxShadow ?? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
Widget card = Container(
|
||||
height: height,
|
||||
width: width,
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
boxShadow: effectiveShadow,
|
||||
border: border,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/// 투명한 배경의 섹션 카드
|
||||
/// 어두운 배경 위에서 사용하기 적합합니다.
|
||||
class TransparentSectionCard extends StatelessWidget {
|
||||
final String? title;
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double opacity;
|
||||
final double borderRadius;
|
||||
final Color? borderColor;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const TransparentSectionCard({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.opacity = 0.15,
|
||||
this.borderRadius = 16,
|
||||
this.borderColor,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget card = Container(
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: opacity),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: borderColor != null
|
||||
? Border.all(color: borderColor!, width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/// 정보 표시용 카드
|
||||
/// 읽기 전용 정보를 표시할 때 사용합니다.
|
||||
class InfoCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData? icon;
|
||||
final Color? iconColor;
|
||||
final Color? backgroundColor;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double borderRadius;
|
||||
|
||||
const InfoCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.icon,
|
||||
this.iconColor,
|
||||
this.backgroundColor,
|
||||
this.padding,
|
||||
this.borderRadius = 12,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: iconColor ?? theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,8 @@ class ConfirmationDialog extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
|
||||
color:
|
||||
(iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 로딩 오버레이 위젯
|
||||
/// 비동기 작업 중 화면을 덮는 로딩 인디케이터를 표시합니다.
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
final Widget child;
|
||||
final String? message;
|
||||
final Color? backgroundColor;
|
||||
final Color? indicatorColor;
|
||||
final double opacity;
|
||||
|
||||
const LoadingOverlay({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.child,
|
||||
this.message,
|
||||
this.backgroundColor,
|
||||
this.indicatorColor,
|
||||
this.opacity = 0.7,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (isLoading)
|
||||
Container(
|
||||
color: (backgroundColor ?? Colors.black).withValues(alpha: opacity),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: indicatorColor ?? Theme.of(context).primaryColor,
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 로딩 다이얼로그
|
||||
/// 모달 형태의 로딩 인디케이터를 표시합니다.
|
||||
class LoadingDialog {
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
String? message,
|
||||
Color? barrierColor,
|
||||
bool barrierDismissible = false,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: barrierColor ?? Colors.black54,
|
||||
builder: (context) => WillPopScope(
|
||||
onWillPop: () async => barrierDismissible,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void hide(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 커스텀 로딩 인디케이터
|
||||
/// 다양한 스타일의 로딩 애니메이션을 제공합니다.
|
||||
class CustomLoadingIndicator extends StatefulWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
final LoadingStyle style;
|
||||
|
||||
const CustomLoadingIndicator({
|
||||
super.key,
|
||||
this.size = 50,
|
||||
this.color,
|
||||
this.style = LoadingStyle.circular,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomLoadingIndicator> createState() => _CustomLoadingIndicatorState();
|
||||
}
|
||||
|
||||
class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(seconds: 1),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
|
||||
_animation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveColor = widget.color ?? Theme.of(context).primaryColor;
|
||||
|
||||
switch (widget.style) {
|
||||
case LoadingStyle.circular:
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: CircularProgressIndicator(
|
||||
color: effectiveColor,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
);
|
||||
|
||||
case LoadingStyle.dots:
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size / 3,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(3, (index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
final delay = index * 0.2;
|
||||
final value = (_animation.value - delay).clamp(0.0, 1.0);
|
||||
return Container(
|
||||
width: widget.size / 5,
|
||||
height: widget.size / 5,
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor.withValues(alpha: 0.3 + value * 0.7),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
case LoadingStyle.pulse:
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: effectiveColor.withValues(alpha: 0.3),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: widget.size * (0.3 + _animation.value * 0.5),
|
||||
height: widget.size * (0.3 + _animation.value * 0.5),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: effectiveColor.withValues(alpha: 1 - _animation.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LoadingStyle {
|
||||
circular,
|
||||
dots,
|
||||
pulse,
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class BaseTextField extends StatelessWidget {
|
||||
if (label != null) ...[
|
||||
Text(
|
||||
label!,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -90,13 +90,14 @@ class BaseTextField extends StatelessWidget {
|
||||
minLines: minLines,
|
||||
readOnly: readOnly,
|
||||
cursorColor: cursorColor ?? theme.primaryColor,
|
||||
style: style ?? TextStyle(
|
||||
style: style ??
|
||||
const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: TextStyle(
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
|
||||
@@ -106,8 +106,6 @@ class BillingCycleSelector extends StatelessWidget {
|
||||
if (isSelected) {
|
||||
return Colors.white;
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.darkNavy
|
||||
: Colors.grey[700]!;
|
||||
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,8 @@ class CategorySelector extends StatelessWidget {
|
||||
Consumer<CategoryProvider>(
|
||||
builder: (context, categoryProvider, child) {
|
||||
return Text(
|
||||
categoryProvider.getLocalizedCategoryName(context, category.name),
|
||||
categoryProvider.getLocalizedCategoryName(
|
||||
context, category.name),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -131,8 +132,6 @@ class CategorySelector extends StatelessWidget {
|
||||
if (isSelected) {
|
||||
return Colors.white;
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.darkNavy
|
||||
: Colors.grey[700]!;
|
||||
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,11 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
}
|
||||
|
||||
double? _parseValue(String text) {
|
||||
final cleanText = text.replaceAll(',', '').replaceAll('₩', '').replaceAll('\$', '').trim();
|
||||
final cleanText = text
|
||||
.replaceAll(',', '')
|
||||
.replaceAll('₩', '')
|
||||
.replaceAll('\$', '')
|
||||
.trim();
|
||||
return double.tryParse(cleanText);
|
||||
}
|
||||
|
||||
@@ -128,10 +132,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
widget.currency == 'KRW'
|
||||
? RegExp(r'[0-9]')
|
||||
: RegExp(r'[0-9.]')
|
||||
),
|
||||
widget.currency == 'KRW' ? RegExp(r'[0-9]') : RegExp(r'[0-9.]')),
|
||||
if (widget.currency == 'USD')
|
||||
// USD의 경우 소수점 이하 2자리까지만 허용
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
@@ -157,7 +158,8 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
final parsedValue = _parseValue(value);
|
||||
widget.onChanged?.call(parsedValue);
|
||||
},
|
||||
validator: widget.validator ?? (value) {
|
||||
validator: widget.validator ??
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return AppLocalizations.of(context).amountRequired;
|
||||
}
|
||||
|
||||
@@ -131,9 +131,7 @@ class _CurrencyOption extends StatelessWidget {
|
||||
|
||||
Color _getBackgroundColor(ThemeData theme) {
|
||||
if (isSelected) {
|
||||
return isGlassmorphism
|
||||
? theme.primaryColor
|
||||
: const Color(0xFF3B82F6);
|
||||
return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6);
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.surfaceColorAlt
|
||||
@@ -154,8 +152,6 @@ class _CurrencyOption extends StatelessWidget {
|
||||
if (isSelected) {
|
||||
return Colors.white;
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.navyGray
|
||||
: Colors.grey[600]!;
|
||||
return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class DatePickerField extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -57,12 +57,15 @@ class DatePickerField extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
focusNode: focusNode,
|
||||
onTap: enabled ? () async {
|
||||
onTap: enabled
|
||||
? () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedDate,
|
||||
firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)),
|
||||
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
firstDate: firstDate ??
|
||||
DateTime.now().subtract(const Duration(days: 365 * 10)),
|
||||
lastDate: lastDate ??
|
||||
DateTime.now().add(const Duration(days: 365 * 10)),
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Theme(
|
||||
data: ThemeData.light().copyWith(
|
||||
@@ -81,7 +84,8 @@ class DatePickerField extends StatelessWidget {
|
||||
if (picked != null && picked != selectedDate) {
|
||||
onDateSelected(picked);
|
||||
}
|
||||
} : null,
|
||||
}
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: contentPadding ?? const EdgeInsets.all(16),
|
||||
@@ -97,21 +101,19 @@ class DatePickerField extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate),
|
||||
DateFormat(effectiveDateFormat, locale.toString())
|
||||
.format(selectedDate),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: enabled
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted,
|
||||
color:
|
||||
enabled ? AppColors.textPrimary : AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: enabled
|
||||
? AppColors.navyGray
|
||||
: AppColors.textMuted,
|
||||
color: enabled ? AppColors.navyGray : AppColors.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -158,7 +160,8 @@ class DateRangePickerField extends StatelessWidget {
|
||||
primaryColor: primaryColor,
|
||||
onDateSelected: onStartDateSelected,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||
lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
lastDate:
|
||||
endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -203,7 +206,8 @@ class _DateRangeItem extends StatelessWidget {
|
||||
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
|
||||
|
||||
return InkWell(
|
||||
onTap: enabled ? () async {
|
||||
onTap: enabled
|
||||
? () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: date ?? DateTime.now(),
|
||||
@@ -227,7 +231,8 @@ class _DateRangeItem extends StatelessWidget {
|
||||
if (picked != null) {
|
||||
onDateSelected(picked);
|
||||
}
|
||||
} : null,
|
||||
}
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -244,7 +249,7 @@ class _DateRangeItem extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
@@ -252,14 +257,14 @@ class _DateRangeItem extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
date != null
|
||||
? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!)
|
||||
? DateFormat(AppLocalizations.of(context).dateFormatShort)
|
||||
.format(date!)
|
||||
: AppLocalizations.of(context).dateSelect,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: date != null
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted,
|
||||
color:
|
||||
date != null ? AppColors.textPrimary : AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -200,7 +200,7 @@ class AppSnackBar {
|
||||
width: 24,
|
||||
height: 24,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: CircularProgressIndicator(
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: AppColors.pureWhite,
|
||||
),
|
||||
|
||||
@@ -44,11 +44,11 @@ class DetailEventSection extends StatelessWidget {
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -118,7 +118,7 @@ class DetailEventSection extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: AppColors.infoColor,
|
||||
size: 20,
|
||||
@@ -127,7 +127,7 @@ class DetailEventSection extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).eventPriceHint,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -146,7 +146,8 @@ class DetailEventSection extends StatelessWidget {
|
||||
controller.eventStartDate = date;
|
||||
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||
if (date != null && controller.eventEndDate == null) {
|
||||
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||
controller.eventEndDate =
|
||||
date.add(const Duration(days: 30));
|
||||
}
|
||||
},
|
||||
onEndDateSelected: (date) {
|
||||
@@ -166,11 +167,14 @@ class DetailEventSection extends StatelessWidget {
|
||||
validator: controller.isEventActive
|
||||
? (value) {
|
||||
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) {
|
||||
return AppLocalizations.of(context).invalidPrice;
|
||||
return AppLocalizations.of(context)
|
||||
.invalidPrice;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -181,9 +185,10 @@ class DetailEventSection extends StatelessWidget {
|
||||
if (controller.eventPriceController.text.isNotEmpty)
|
||||
_DiscountBadge(
|
||||
originalPrice: controller.subscription.monthlyCost,
|
||||
eventPrice: double.tryParse(
|
||||
controller.eventPriceController.text.replaceAll(',', '')
|
||||
) ?? 0,
|
||||
eventPrice: double.tryParse(controller
|
||||
.eventPriceController.text
|
||||
.replaceAll(',', '')) ??
|
||||
0,
|
||||
currency: controller.currency,
|
||||
),
|
||||
],
|
||||
@@ -216,7 +221,8 @@ class _DiscountBadge extends StatelessWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round();
|
||||
final discountPercentage =
|
||||
((originalPrice - eventPrice) / originalPrice * 100).round();
|
||||
final discountAmount = originalPrice - eventPrice;
|
||||
|
||||
return Container(
|
||||
@@ -234,7 +240,9 @@ class _DiscountBadge extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()),
|
||||
AppLocalizations.of(context)
|
||||
.discountPercent
|
||||
.replaceAll('@', discountPercentage.toString()),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
@@ -245,8 +253,8 @@ class _DiscountBadge extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_getLocalizedDiscountAmount(context, currency, discountAmount),
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF15803D),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF15803D),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -256,7 +264,8 @@ class _DiscountBadge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _getLocalizedDiscountAmount(BuildContext context, String currency, double amount) {
|
||||
String _getLocalizedDiscountAmount(
|
||||
BuildContext context, String currency, double amount) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
switch (currency) {
|
||||
case 'KRW':
|
||||
@@ -264,9 +273,11 @@ class _DiscountBadge extends StatelessWidget {
|
||||
case 'JPY':
|
||||
return loc.discountAmountYen.replaceAll('@', amount.toInt().toString());
|
||||
case 'CNY':
|
||||
return loc.discountAmountYuan.replaceAll('@', amount.toStringAsFixed(2));
|
||||
return loc.discountAmountYuan
|
||||
.replaceAll('@', amount.toStringAsFixed(2));
|
||||
default: // USD
|
||||
return loc.discountAmountDollar.replaceAll('@', amount.toStringAsFixed(2));
|
||||
return loc.discountAmountDollar
|
||||
.replaceAll('@', amount.toStringAsFixed(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,11 @@ class DetailFormSection extends StatelessWidget {
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -114,9 +114,9 @@ class DetailFormSection extends StatelessWidget {
|
||||
controller.currency = value;
|
||||
// 통화 변경시 금액 포맷 업데이트
|
||||
if (value == 'KRW') {
|
||||
final amount = double.tryParse(
|
||||
controller.monthlyCostController.text.replaceAll(',', '')
|
||||
);
|
||||
final amount = double.tryParse(controller
|
||||
.monthlyCostController.text
|
||||
.replaceAll(',', ''));
|
||||
if (amount != null) {
|
||||
controller.monthlyCostController.text =
|
||||
amount.toInt().toString();
|
||||
@@ -164,7 +164,8 @@ class DetailFormSection extends StatelessWidget {
|
||||
},
|
||||
label: AppLocalizations.of(context).nextBillingDate,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
lastDate:
|
||||
DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
primaryColor: baseColor,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
@@ -207,4 +208,3 @@ class DetailFormSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../controllers/detail_screen_controller.dart';
|
||||
import '../../providers/locale_provider.dart';
|
||||
@@ -115,7 +114,8 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
color: Colors.black
|
||||
.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
@@ -124,8 +124,10 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: WebsiteIcon(
|
||||
url: controller.websiteUrlController.text,
|
||||
serviceName: controller.serviceNameController.text,
|
||||
url: controller
|
||||
.websiteUrlController.text,
|
||||
serviceName: controller
|
||||
.serviceNameController.text,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
@@ -134,10 +136,13 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
controller.displayName ?? controller.serviceNameController.text,
|
||||
controller.displayName ??
|
||||
controller
|
||||
.serviceNameController.text,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -154,12 +159,18 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context).billingCyclePayment.replaceAll('@',
|
||||
_getLocalizedBillingCycle(context, controller.billingCycle)),
|
||||
AppLocalizations.of(context)
|
||||
.billingCyclePayment
|
||||
.replaceAll(
|
||||
'@',
|
||||
_getLocalizedBillingCycle(
|
||||
context,
|
||||
controller.billingCycle)),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
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),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_InfoColumn(
|
||||
label: AppLocalizations.of(context).nextBillingDate,
|
||||
value: AppLocalizations.of(context).formatDate(controller.nextBillingDate),
|
||||
label: AppLocalizations.of(context)
|
||||
.nextBillingDate,
|
||||
value: AppLocalizations.of(context)
|
||||
.formatDate(
|
||||
controller.nextBillingDate),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: () async {
|
||||
final locale = context.read<LocaleProvider>().locale.languageCode;
|
||||
final locale = context
|
||||
.read<LocaleProvider>()
|
||||
.locale
|
||||
.languageCode;
|
||||
final amount = double.tryParse(
|
||||
controller.monthlyCostController.text.replaceAll(',', '')
|
||||
) ?? 0;
|
||||
return CurrencyUtil.formatAmountWithLocale(
|
||||
controller
|
||||
.monthlyCostController.text
|
||||
.replaceAll(',', '')) ??
|
||||
0;
|
||||
return CurrencyUtil
|
||||
.formatAmountWithLocale(
|
||||
amount,
|
||||
controller.currency,
|
||||
locale,
|
||||
@@ -196,7 +217,8 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
}(),
|
||||
builder: (context, snapshot) {
|
||||
return _InfoColumn(
|
||||
label: AppLocalizations.of(context).monthlyExpense,
|
||||
label: AppLocalizations.of(context)
|
||||
.monthlyExpense,
|
||||
value: snapshot.data ?? '-',
|
||||
alignment: CrossAxisAlignment.end,
|
||||
);
|
||||
@@ -219,6 +241,7 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getLocalizedBillingCycle(BuildContext context, String cycle) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
switch (cycle.toLowerCase()) {
|
||||
|
||||
@@ -41,11 +41,11 @@ class DetailUrlSection extends StatelessWidget {
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -89,7 +89,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
label: AppLocalizations.of(context).websiteUrl,
|
||||
hintText: AppLocalizations.of(context).urlExample,
|
||||
keyboardType: TextInputType.url,
|
||||
prefixIcon: Icon(
|
||||
prefixIcon: const Icon(
|
||||
Icons.link_rounded,
|
||||
color: AppColors.navyGray,
|
||||
),
|
||||
@@ -114,7 +114,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: AppColors.warningColor,
|
||||
size: 20,
|
||||
@@ -122,7 +122,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).cancelGuide,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
@@ -133,7 +133,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).cancelServiceGuide,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -167,7 +167,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.auto_fix_high_rounded,
|
||||
color: AppColors.infoColor,
|
||||
size: 20,
|
||||
@@ -176,7 +176,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).urlAutoMatchInfo,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
import '../../utils/reduce_motion.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../common/buttons/primary_button.dart';
|
||||
import '../common/buttons/secondary_button.dart';
|
||||
@@ -27,7 +28,10 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4),
|
||||
sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassCard.withValues(alpha: 0.8),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'glassmorphism_card.dart';
|
||||
import 'themed_text.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||
///
|
||||
@@ -25,16 +26,20 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final beginOffset = ReduceMotion.isEnabled(context)
|
||||
? const Offset(0, 0.05)
|
||||
: const Offset(0, 0.2);
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Center(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
begin: beginOffset,
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutBack)),
|
||||
child: RepaintBoundary(
|
||||
child: GlassmorphismCard(
|
||||
width: null,
|
||||
margin: const EdgeInsets.all(16),
|
||||
@@ -45,8 +50,11 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
AnimatedBuilder(
|
||||
animation: rotateController,
|
||||
builder: (context, child) {
|
||||
final angleScale =
|
||||
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
|
||||
return Transform.rotate(
|
||||
angle: rotateController.value * 2 * math.pi,
|
||||
angle:
|
||||
angleScale * rotateController.value * 2 * math.pi,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
@@ -58,7 +66,8 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
color: AppColors.primaryColor
|
||||
.withValues(alpha: 0.3),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
@@ -110,7 +119,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(context).addSubscription,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
@@ -124,6 +133,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/exchange_rate_service.dart';
|
||||
|
||||
/// 환율 정보를 표시하는 위젯
|
||||
/// 달러 금액을 입력받아 원화 금액으로 변환하여 표시합니다.
|
||||
class ExchangeRateWidget extends StatefulWidget {
|
||||
/// 달러 금액 변화 감지용 TextEditingController
|
||||
final TextEditingController costController;
|
||||
|
||||
/// 환율 정보를 보여줄지 여부 (통화가 달러일 때만 true)
|
||||
final bool showExchangeRate;
|
||||
|
||||
const ExchangeRateWidget({
|
||||
Key? key,
|
||||
required this.costController,
|
||||
required this.showExchangeRate,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ExchangeRateWidget> createState() => _ExchangeRateWidgetState();
|
||||
}
|
||||
|
||||
class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
|
||||
final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||
String _exchangeRateInfo = '';
|
||||
String _convertedAmount = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExchangeRate();
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.costController.removeListener(_updateConvertedAmount);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ExchangeRateWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 통화 변경 감지(달러->원화 또는 원화->달러)되면 리스너 해제 및 재등록
|
||||
if (oldWidget.showExchangeRate != widget.showExchangeRate) {
|
||||
oldWidget.costController.removeListener(_updateConvertedAmount);
|
||||
|
||||
if (widget.showExchangeRate) {
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
_loadExchangeRate();
|
||||
_updateConvertedAmount();
|
||||
} else {
|
||||
setState(() {
|
||||
_exchangeRateInfo = '';
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 로드
|
||||
Future<void> _loadExchangeRate() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_exchangeRateInfo = rateInfo;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 달러 금액이 변경될 때 원화 금액 업데이트
|
||||
Future<void> _updateConvertedAmount() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
try {
|
||||
// 금액 입력값에서 콤마 제거 후 숫자로 변환
|
||||
final text = widget.costController.text.replaceAll(',', '');
|
||||
if (text.isEmpty) {
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(text);
|
||||
if (amount != null) {
|
||||
final converted =
|
||||
await _exchangeRateService.getFormattedKrwAmount(amount);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_convertedAmount = converted;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 발생 시 빈 문자열 표시
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 텍스트 위젯 생성
|
||||
Widget buildExchangeRateInfo() {
|
||||
if (_exchangeRateInfo.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_exchangeRateInfo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 환산 금액 텍스트 위젯 생성
|
||||
Widget buildConvertedAmount() {
|
||||
if (_convertedAmount.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_convertedAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.showExchangeRate) {
|
||||
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
|
||||
}
|
||||
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 익스포즈드 메서드: 환율 정보 문자열 가져오기
|
||||
String get exchangeRateInfo => _exchangeRateInfo;
|
||||
|
||||
// 익스포즈드 메서드: 변환된 금액 문자열 가져오기
|
||||
String get convertedAmount => _convertedAmount;
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/haptic_feedback_helper.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class ExpandableFab extends StatefulWidget {
|
||||
final List<FabAction> actions;
|
||||
final double distance;
|
||||
|
||||
const ExpandableFab({
|
||||
super.key,
|
||||
required this.actions,
|
||||
this.distance = 100.0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExpandableFab> createState() => _ExpandableFabState();
|
||||
}
|
||||
|
||||
class _ExpandableFabState extends State<ExpandableFab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _expandAnimation;
|
||||
late Animation<double> _rotateAnimation;
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_expandAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutBack,
|
||||
reverseCurve: Curves.easeInBack,
|
||||
);
|
||||
|
||||
_rotateAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: math.pi / 4,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
|
||||
if (_isExpanded) {
|
||||
HapticFeedbackHelper.mediumImpact();
|
||||
_controller.forward();
|
||||
} else {
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
// 배경 오버레이 (확장 시)
|
||||
if (_isExpanded)
|
||||
GestureDetector(
|
||||
onTap: _toggle,
|
||||
child: AnimatedBuilder(
|
||||
animation: _expandAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
color: AppColors.shadowBlack.withValues(alpha: 3.75 * _expandAnimation.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 액션 버튼들
|
||||
...widget.actions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final action = entry.value;
|
||||
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _expandAnimation,
|
||||
builder: (context, child) {
|
||||
final distance = widget.distance * _expandAnimation.value;
|
||||
final x = distance * math.cos(angle);
|
||||
final y = distance * math.sin(angle);
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(-x, -y),
|
||||
child: ScaleTransition(
|
||||
scale: _expandAnimation,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'fab_action_$index',
|
||||
onPressed: _isExpanded
|
||||
? () {
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
_toggle();
|
||||
action.onPressed();
|
||||
}
|
||||
: null,
|
||||
backgroundColor: action.color ?? AppColors.primaryColor,
|
||||
child: Icon(
|
||||
action.icon,
|
||||
size: 20,
|
||||
color: AppColors.pureWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
// 메인 FAB
|
||||
AnimatedBuilder(
|
||||
animation: _rotateAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.rotate(
|
||||
angle: _rotateAnimation.value,
|
||||
child: FloatingActionButton(
|
||||
onPressed: _toggle,
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
child: Icon(
|
||||
_isExpanded ? Icons.close : Icons.add,
|
||||
size: 28,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 라벨 표시
|
||||
if (_isExpanded)
|
||||
...widget.actions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final action = entry.value;
|
||||
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _expandAnimation,
|
||||
builder: (context, child) {
|
||||
final distance = widget.distance * _expandAnimation.value;
|
||||
final x = distance * math.cos(angle);
|
||||
final y = distance * math.sin(angle);
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(-x - 80, -y),
|
||||
child: FadeTransition(
|
||||
opacity: _expandAnimation,
|
||||
child: GlassmorphismCard(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
borderRadius: 8,
|
||||
blur: 10,
|
||||
child: Text(
|
||||
action.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FabAction {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final Color? color;
|
||||
|
||||
const FabAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.color,
|
||||
});
|
||||
}
|
||||
|
||||
// 드래그 가능한 FAB
|
||||
class DraggableFab extends StatefulWidget {
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const DraggableFab({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DraggableFab> createState() => _DraggableFabState();
|
||||
}
|
||||
|
||||
class _DraggableFabState extends State<DraggableFab> {
|
||||
Offset _position = const Offset(20, 20);
|
||||
bool _isDragging = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final padding = widget.padding ?? const EdgeInsets.all(20);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: _position.dx,
|
||||
bottom: _position.dy,
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
setState(() => _isDragging = true);
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
_position = Offset(
|
||||
(_position.dx - details.delta.dx).clamp(
|
||||
padding.right,
|
||||
screenSize.width - 100 - padding.left,
|
||||
),
|
||||
(_position.dy - details.delta.dy).clamp(
|
||||
padding.bottom,
|
||||
screenSize.height - 200 - padding.top,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
setState(() => _isDragging = false);
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
},
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
scale: _isDragging ? 0.9 : 1.0,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../theme/app_colors.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/platform_helper.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
class FloatingNavigationBar extends StatefulWidget {
|
||||
final int selectedIndex;
|
||||
@@ -30,7 +31,9 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
@@ -72,9 +75,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
right: 16,
|
||||
height: 88,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 100 * (1 - _animation.value)),
|
||||
offset: Offset(
|
||||
0,
|
||||
ReduceMotion.isEnabled(context)
|
||||
? 0
|
||||
: 100 * (1 - _animation.value)),
|
||||
child: Opacity(
|
||||
opacity: _animation.value,
|
||||
opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4), // 그림자 공간 확보
|
||||
decoration: BoxDecoration(
|
||||
@@ -124,8 +131,11 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
_NavigationItem(
|
||||
icon: Icons.settings_rounded,
|
||||
label: AppLocalizations.of(context).settings,
|
||||
isSelected: PlatformHelper.isIOS ? widget.selectedIndex == 3 : widget.selectedIndex == 4,
|
||||
onTap: () => _onItemTapped(PlatformHelper.isIOS ? 3 : 4),
|
||||
isSelected: PlatformHelper.isIOS
|
||||
? widget.selectedIndex == 3
|
||||
: widget.selectedIndex == 4,
|
||||
onTap: () =>
|
||||
_onItemTapped(PlatformHelper.isIOS ? 3 : 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -217,12 +227,14 @@ class _AddButtonState extends State<_AddButton>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.9,
|
||||
end: ReduceMotion.platform() ? 1.0 : 0.9,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'themed_text.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 글래스모피즘 효과가 적용된 통일된 앱바
|
||||
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double elevation;
|
||||
final Color? backgroundColor;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final PreferredSizeWidget? bottom;
|
||||
final bool centerTitle;
|
||||
final double? titleSpacing;
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
const GlassmorphicAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.elevation = 0,
|
||||
this.backgroundColor,
|
||||
this.blur = 20,
|
||||
this.opacity = 0.1,
|
||||
this.bottom,
|
||||
this.centerTitle = false,
|
||||
this.titleSpacing,
|
||||
this.onBackPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(
|
||||
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final canPop = Navigator.of(context).canPop();
|
||||
|
||||
return ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(backgroundColor ?? (isDarkMode
|
||||
? AppColors.glassBackgroundDark
|
||||
: AppColors.glassBackground)).withValues(alpha: opacity),
|
||||
(backgroundColor ?? (isDarkMode
|
||||
? AppColors.glassSurfaceDark
|
||||
: AppColors.glassSurface)).withValues(alpha: opacity * 0.8),
|
||||
],
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDarkMode
|
||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||
: AppColors.glassBorder.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: NavigationToolbar(
|
||||
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
|
||||
? _buildBackButton(context)
|
||||
: null),
|
||||
middle: _buildTitle(context),
|
||||
trailing: actions != null
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: actions!,
|
||||
)
|
||||
: null,
|
||||
centerMiddle: centerTitle,
|
||||
middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bottom != null) bottom!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButton(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: onBackPressed ?? () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: AppLocalizations.of(context).back,
|
||||
color: ThemedText.getContrastColor(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 투명 스타일 팩토리
|
||||
static GlassmorphicAppBar transparent({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
VoidCallback? onBackPressed,
|
||||
}) {
|
||||
return GlassmorphicAppBar(
|
||||
title: title,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
blur: 30,
|
||||
opacity: 0.05,
|
||||
onBackPressed: onBackPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 반투명 스타일 팩토리
|
||||
static GlassmorphicAppBar translucent({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
VoidCallback? onBackPressed,
|
||||
}) {
|
||||
return GlassmorphicAppBar(
|
||||
title: title,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
blur: 20,
|
||||
opacity: 0.15,
|
||||
onBackPressed: onBackPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함)
|
||||
class GlassmorphicSliverAppBar extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final double expandedHeight;
|
||||
final bool floating;
|
||||
final bool pinned;
|
||||
final bool snap;
|
||||
final Widget? flexibleSpace;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final bool automaticallyImplyLeading;
|
||||
final VoidCallback? onBackPressed;
|
||||
final bool centerTitle;
|
||||
|
||||
const GlassmorphicSliverAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.expandedHeight = kToolbarHeight,
|
||||
this.floating = false,
|
||||
this.pinned = true,
|
||||
this.snap = false,
|
||||
this.flexibleSpace,
|
||||
this.blur = 20,
|
||||
this.opacity = 0.1,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.onBackPressed,
|
||||
this.centerTitle = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final canPop = Navigator.of(context).canPop();
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
floating: floating,
|
||||
pinned: pinned,
|
||||
snap: snap,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: onBackPressed ?? () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: AppLocalizations.of(context).back,
|
||||
)
|
||||
: null),
|
||||
actions: actions,
|
||||
centerTitle: centerTitle,
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final top = constraints.biggest.height;
|
||||
final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
title: isCollapsed
|
||||
? ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
centerTitle: centerTitle,
|
||||
titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16),
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 글래스모피즘 배경
|
||||
ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(isDarkMode
|
||||
? AppColors.glassBackgroundDark
|
||||
: AppColors.glassBackground).withValues(alpha: opacity),
|
||||
(isDarkMode
|
||||
? AppColors.glassSurfaceDark
|
||||
: AppColors.glassSurface).withValues(alpha: opacity * 0.8),
|
||||
],
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDarkMode
|
||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||
: AppColors.glassBorder.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 확장 상태에서만 보이는 타이틀
|
||||
if (!isCollapsed)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 커스텀 flexibleSpace가 있으면 추가
|
||||
if (flexibleSpace != null) flexibleSpace!,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,11 +80,14 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
||||
final currentScroll = _scrollController!.position.pixels;
|
||||
|
||||
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
|
||||
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {
|
||||
if (currentScroll > 50 &&
|
||||
_scrollController!.position.userScrollDirection ==
|
||||
ScrollDirection.reverse) {
|
||||
if (_isFloatingNavBarVisible) {
|
||||
setState(() => _isFloatingNavBarVisible = false);
|
||||
}
|
||||
} else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) {
|
||||
} else if (_scrollController!.position.userScrollDirection ==
|
||||
ScrollDirection.forward) {
|
||||
if (!_isFloatingNavBarVisible) {
|
||||
setState(() => _isFloatingNavBarVisible = true);
|
||||
}
|
||||
@@ -159,7 +162,9 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: gradientColors.map((color) => color.withOpacity(0.3)).toList(),
|
||||
colors: gradientColors
|
||||
.map((color) => color.withValues(alpha: 0.3))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -172,10 +177,13 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
||||
child: AnimatedBuilder(
|
||||
animation: _particleController,
|
||||
builder: (context, child) {
|
||||
final media = MediaQuery.maybeOf(context);
|
||||
final reduce = media?.disableAnimations ?? false;
|
||||
final count = reduce ? 10 : 30;
|
||||
return CustomPaint(
|
||||
painter: ParticlePainter(
|
||||
animation: _particleController,
|
||||
particleCount: 30,
|
||||
particleCount: count,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -273,7 +281,9 @@ class WavePainter extends CustomPainter {
|
||||
path.moveTo(0, size.height);
|
||||
|
||||
for (double x = 0; x <= size.width; x++) {
|
||||
final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 +
|
||||
final y =
|
||||
math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) *
|
||||
20 +
|
||||
size.height * 0.5;
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
import 'themed_text.dart';
|
||||
|
||||
class GlassmorphismCard extends StatelessWidget {
|
||||
@@ -51,12 +53,18 @@ class GlassmorphismCard extends StatelessWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: ReduceMotion.scale(context,
|
||||
normal: blur, reduced: blur * 0.4),
|
||||
sigmaY: ReduceMotion.scale(context,
|
||||
normal: blur, reduced: blur * 0.4),
|
||||
),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? AppColors.glassCard,
|
||||
gradient: gradient ?? LinearGradient(
|
||||
gradient: gradient ??
|
||||
LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDarkMode
|
||||
@@ -64,16 +72,20 @@ class GlassmorphismCard extends StatelessWidget {
|
||||
: AppColors.glassGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: border ?? Border.all(
|
||||
border: border ??
|
||||
Border.all(
|
||||
color: isDarkMode
|
||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||
: AppColors.glassBorder,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: boxShadow ?? [
|
||||
boxShadow: boxShadow ??
|
||||
[
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
|
||||
blurRadius: 20,
|
||||
color: AppColors
|
||||
.shadowBlack, // color.md: rgba(0,0,0,0.08)
|
||||
blurRadius: ReduceMotion.scale(context,
|
||||
normal: 20, reduced: 10),
|
||||
spreadRadius: -5,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
@@ -119,7 +131,8 @@ class AnimatedGlassmorphismCard extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedGlassmorphismCard> createState() => _AnimatedGlassmorphismCardState();
|
||||
State<AnimatedGlassmorphismCard> createState() =>
|
||||
_AnimatedGlassmorphismCardState();
|
||||
}
|
||||
|
||||
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
@@ -195,7 +208,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
_handleTapUp(details);
|
||||
// onTap 콜백 실행
|
||||
if (widget.onTap != null) {
|
||||
print('[AnimatedGlassmorphismCard] onTap 콜백 실행');
|
||||
Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
|
||||
widget.onTap!();
|
||||
}
|
||||
},
|
||||
@@ -203,15 +216,18 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final scaleValue = ReduceMotion.scale(context,
|
||||
normal: _scaleAnimation.value, reduced: 1.0);
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
scale: scaleValue,
|
||||
child: GlassmorphismCard(
|
||||
padding: widget.padding,
|
||||
margin: widget.margin,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
borderRadius: widget.borderRadius,
|
||||
blur: _blurAnimation.value,
|
||||
blur: ReduceMotion.scale(context,
|
||||
normal: _blurAnimation.value, reduced: widget.blur),
|
||||
opacity: widget.opacity,
|
||||
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
|
||||
child: widget.child,
|
||||
|
||||
@@ -52,8 +52,10 @@ class HomeContent extends StatelessWidget {
|
||||
}
|
||||
|
||||
// 카테고리별 구독 구분
|
||||
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions(
|
||||
final categoryProvider =
|
||||
Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categorizedSubscriptions =
|
||||
SubscriptionCategoryHelper.categorizeSubscriptions(
|
||||
provider.subscriptions,
|
||||
categoryProvider,
|
||||
context,
|
||||
@@ -120,7 +122,8 @@ class HomeContent extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length),
|
||||
AppLocalizations.of(context)
|
||||
.subscriptionCount(provider.subscriptions.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -42,6 +42,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
|
||||
child: RepaintBoundary(
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 16,
|
||||
blur: 15,
|
||||
@@ -88,8 +89,9 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).monthlyTotalSubscriptionCost,
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(context)
|
||||
.monthlyTotalSubscriptionCost,
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 15,
|
||||
@@ -99,9 +101,12 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
// 환율 정보 표시 (영어 사용자는 표시 안함)
|
||||
if (locale != 'en')
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.getExchangeRateInfoForLocale(locale),
|
||||
future:
|
||||
CurrencyUtil.getExchangeRateInfoForLocale(
|
||||
locale),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||
if (snapshot.hasData &&
|
||||
snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
@@ -109,14 +114,17 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F2FF),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFBFDBFE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).exchangeRateDisplay.replaceAll('@', snapshot.data!),
|
||||
AppLocalizations.of(context)
|
||||
.exchangeRateDisplay
|
||||
.replaceAll('@', snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -133,7 +141,8 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
// 월별 총 비용 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
@@ -142,7 +151,10 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
|
||||
final decimals = (defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
@@ -150,9 +162,13 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW' ? 'ko_KR' :
|
||||
defaultCurrency == 'JPY' ? 'ja_JP' :
|
||||
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
|
||||
locale: defaultCurrency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency == 'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: '',
|
||||
decimalDigits: decimals,
|
||||
).format(monthlyCost),
|
||||
@@ -179,7 +195,8 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
// 연간 비용 및 총 구독 수 표시
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
@@ -189,26 +206,36 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final yearlyCost = monthlyCost * 12;
|
||||
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
|
||||
final decimals = (defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context).estimatedAnnualCost,
|
||||
value: '${NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW' ? 'ko_KR' :
|
||||
defaultCurrency == 'JPY' ? 'ja_JP' :
|
||||
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
|
||||
title: AppLocalizations.of(context)
|
||||
.estimatedAnnualCost,
|
||||
value: NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency == 'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(yearlyCost)}',
|
||||
).format(yearlyCost),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context).totalSubscriptionServices,
|
||||
value: '$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
|
||||
title: AppLocalizations.of(context)
|
||||
.totalSubscriptionServices,
|
||||
value:
|
||||
'$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -240,7 +267,8 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
color:
|
||||
Colors.white.withValues(alpha: 0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
@@ -252,11 +280,13 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).eventDiscountActive,
|
||||
style: TextStyle(
|
||||
AppLocalizations.of(context)
|
||||
.eventDiscountActive,
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 11,
|
||||
@@ -266,7 +296,8 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
// 이벤트 절약액 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil.calculateTotalEventSavingsInDefaultCurrency(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalEventSavingsInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
@@ -275,15 +306,25 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
return const SizedBox();
|
||||
}
|
||||
final eventSavings = snapshot.data!;
|
||||
final decimals = (defaultCurrency == 'KRW' || defaultCurrency == 'JPY') ? 0 : 2;
|
||||
final decimals =
|
||||
(defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW' ? 'ko_KR' :
|
||||
defaultCurrency == 'JPY' ? 'ja_JP' :
|
||||
defaultCurrency == 'CNY' ? 'zh_CN' : 'en_US',
|
||||
locale: defaultCurrency ==
|
||||
'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency ==
|
||||
'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(eventSavings),
|
||||
@@ -320,6 +361,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -337,7 +379,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
@@ -44,7 +44,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
}
|
||||
|
||||
_nativeAd = NativeAd(
|
||||
adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요
|
||||
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
|
||||
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함
|
||||
request: const AdRequest(),
|
||||
listener: NativeAdListener(
|
||||
@@ -63,15 +63,15 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
)..load();
|
||||
}
|
||||
|
||||
/// 테스트 광고 단위 ID 반환 함수
|
||||
/// 광고 단위 ID 반환 함수
|
||||
/// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용
|
||||
String _testAdUnitId() {
|
||||
if (Platform.isAndroid) {
|
||||
// Android 테스트 네이티브 광고 ID
|
||||
return 'ca-app-pub-3940256099942544/2247696110';
|
||||
// Android 네이티브 광고 ID
|
||||
return 'ca-app-pub-6691216385521068/4512709971';
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS 테스트 네이티브 광고 ID
|
||||
return 'ca-app-pub-3940256099942544/3986624511';
|
||||
// iOS 네이티브 광고 ID
|
||||
return 'ca-app-pub-6691216385521068/4512709971';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class SkeletonLoading extends StatelessWidget {
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
|
||||
const SkeletonLoading({
|
||||
Key? key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius = 8.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 단일 스켈레톤 아이템이 요청된 경우
|
||||
if (width != null || height != null) {
|
||||
return _buildSingleSkeleton();
|
||||
}
|
||||
|
||||
// 기본 전체 화면 스켈레톤
|
||||
return Column(
|
||||
children: [
|
||||
// 요약 카드 스켈레톤
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSkeletonColumn(),
|
||||
_buildSkeletonColumn(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 구독 목록 스켈레톤
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSingleSkeleton() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
Colors.grey[300]!,
|
||||
Colors.grey[100]!,
|
||||
Colors.grey[300]!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonColumn() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -43,7 +43,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
// URL 필드 자동 설정
|
||||
if (widget.websiteUrlController.text.isEmpty && widget.subscription.websiteUrl != null) {
|
||||
if (widget.websiteUrlController.text.isEmpty &&
|
||||
widget.subscription.websiteUrl != null) {
|
||||
widget.websiteUrlController.text = widget.subscription.websiteUrl!;
|
||||
}
|
||||
}
|
||||
@@ -256,7 +257,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
|
||||
const SizedBox(height: 8),
|
||||
CategorySelector(
|
||||
categories: categoryProvider.categories,
|
||||
selectedCategoryId: widget.selectedCategoryId ?? widget.subscription.category,
|
||||
selectedCategoryId:
|
||||
widget.selectedCategoryId ?? widget.subscription.category,
|
||||
onChanged: widget.onCategoryChanged,
|
||||
baseColor: _getCategoryColor(categoryProvider),
|
||||
isGlassmorphism: true,
|
||||
@@ -304,7 +306,8 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
|
||||
}
|
||||
|
||||
Color? _getCategoryColor(CategoryProvider categoryProvider) {
|
||||
final categoryId = widget.selectedCategoryId ?? widget.subscription.category;
|
||||
final categoryId =
|
||||
widget.selectedCategoryId ?? widget.subscription.category;
|
||||
if (categoryId == null) return null;
|
||||
|
||||
final category = categoryProvider.getCategoryById(categoryId);
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
|
||||
class SpringAnimationWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Duration delay;
|
||||
final SpringDescription spring;
|
||||
final Offset? initialOffset;
|
||||
final double? initialScale;
|
||||
final double? initialRotation;
|
||||
|
||||
const SpringAnimationWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.delay = Duration.zero,
|
||||
this.spring = const SpringDescription(
|
||||
mass: 1,
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
),
|
||||
this.initialOffset,
|
||||
this.initialScale,
|
||||
this.initialRotation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
|
||||
}
|
||||
|
||||
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _offsetAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _rotationAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
// 오프셋 애니메이션
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
begin: widget.initialOffset ?? const Offset(0, 50),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 스케일 애니메이션
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: widget.initialScale ?? 0.5,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 회전 애니메이션
|
||||
_rotationAnimation = Tween<double>(
|
||||
begin: widget.initialRotation ?? 0.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 지연 후 애니메이션 시작
|
||||
Future.delayed(widget.delay, () {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: _offsetAnimation.value,
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Transform.rotate(
|
||||
angle: _rotationAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 바운스 효과가 있는 버튼
|
||||
class BouncyButton extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final BoxDecoration? decoration;
|
||||
|
||||
const BouncyButton({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.padding,
|
||||
this.decoration,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BouncyButton> createState() => _BouncyButtonState();
|
||||
}
|
||||
|
||||
class _BouncyButtonState extends State<BouncyButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.95,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
padding: widget.padding,
|
||||
decoration: widget.decoration,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 중력 효과 애니메이션
|
||||
class GravityAnimation extends StatefulWidget {
|
||||
final Widget child;
|
||||
final double gravity;
|
||||
final double bounceFactor;
|
||||
final double initialVelocity;
|
||||
|
||||
const GravityAnimation({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.gravity = 9.8,
|
||||
this.bounceFactor = 0.8,
|
||||
this.initialVelocity = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GravityAnimation> createState() => _GravityAnimationState();
|
||||
}
|
||||
|
||||
class _GravityAnimationState extends State<GravityAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
double _position = 0;
|
||||
double _velocity = 0;
|
||||
final double _floor = 300;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_velocity = widget.initialVelocity;
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 10),
|
||||
)..addListener(_updatePhysics);
|
||||
|
||||
_controller.repeat();
|
||||
}
|
||||
|
||||
void _updatePhysics() {
|
||||
setState(() {
|
||||
// 속도 업데이트 (중력 적용)
|
||||
_velocity += widget.gravity * 0.016; // 60fps 가정
|
||||
|
||||
// 위치 업데이트
|
||||
_position += _velocity;
|
||||
|
||||
// 바닥 충돌 감지
|
||||
if (_position >= _floor) {
|
||||
_position = _floor;
|
||||
_velocity = -_velocity * widget.bounceFactor;
|
||||
|
||||
// 너무 작은 바운스는 멈춤
|
||||
if (_velocity.abs() < 1) {
|
||||
_velocity = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, _position),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 물결 효과 애니메이션
|
||||
class RippleAnimation extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Color rippleColor;
|
||||
final Duration duration;
|
||||
|
||||
const RippleAnimation({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.rippleColor = Colors.blue,
|
||||
this.duration = const Duration(milliseconds: 600),
|
||||
});
|
||||
|
||||
@override
|
||||
State<RippleAnimation> createState() => _RippleAnimationState();
|
||||
}
|
||||
|
||||
class _RippleAnimationState extends State<RippleAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
_controller.forward(from: 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _handleTap,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 100 + 200 * _animation.value,
|
||||
height: 100 + 200 * _animation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.rippleColor.withValues(alpha:
|
||||
(1 - _animation.value) * 0.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
widget.child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
/// 스태거 애니메이션이 적용된 리스트 위젯
|
||||
class StaggeredListAnimation extends StatefulWidget {
|
||||
@@ -95,13 +96,14 @@ class _StaggeredListAnimationState extends State<StaggeredListAnimation>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ReduceMotion.platform()) {
|
||||
return widget.direction == Axis.vertical
|
||||
? Column(
|
||||
children: _buildAnimatedChildren(),
|
||||
)
|
||||
: Row(
|
||||
children: _buildAnimatedChildren(),
|
||||
);
|
||||
? Column(children: widget.children)
|
||||
: Row(children: widget.children);
|
||||
}
|
||||
return widget.direction == Axis.vertical
|
||||
? Column(children: _buildAnimatedChildren())
|
||||
: Row(children: _buildAnimatedChildren());
|
||||
}
|
||||
|
||||
List<Widget> _buildAnimatedChildren() {
|
||||
@@ -156,8 +158,9 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final reduced = ReduceMotion.platform();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
duration: reduced ? Duration.zero : widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
@@ -170,7 +173,9 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
begin: ReduceMotion.platform()
|
||||
? const Offset(0, 0.05)
|
||||
: const Offset(0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
@@ -185,11 +190,11 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
||||
curve: widget.curve,
|
||||
));
|
||||
|
||||
// 지연 후 애니메이션 시작
|
||||
Future.delayed(widget.delay * widget.index, () {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
}
|
||||
// 지연 후 애니메이션 시작 (모션 축소 시 지연 없음)
|
||||
final startDelay =
|
||||
ReduceMotion.platform() ? Duration.zero : widget.delay * widget.index;
|
||||
Future.delayed(startDelay, () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,6 +206,7 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ReduceMotion.platform()) return widget.child;
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
@@ -60,7 +59,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SubscriptionCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -203,7 +201,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
daysUntilNext = 7; // 다음 주 같은 요일
|
||||
}
|
||||
|
||||
if (daysUntilNext == 0) return AppLocalizations.of(context).paymentDueToday;
|
||||
if (daysUntilNext == 0) {
|
||||
return AppLocalizations.of(context).paymentDueToday;
|
||||
}
|
||||
return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
|
||||
}
|
||||
|
||||
@@ -234,15 +234,15 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
}
|
||||
|
||||
final categoryProvider = context.watch<CategoryProvider>();
|
||||
final category = categoryProvider.getCategoryById(widget.subscription.categoryId!);
|
||||
final category =
|
||||
categoryProvider.getCategoryById(widget.subscription.categoryId!);
|
||||
|
||||
if (category == null) {
|
||||
return AppColors.blueGradient;
|
||||
}
|
||||
|
||||
final categoryColor = Color(
|
||||
int.parse(category.color.replaceAll('#', '0xFF'))
|
||||
);
|
||||
final categoryColor =
|
||||
Color(int.parse(category.color.replaceAll('#', '0xFF')));
|
||||
|
||||
return [
|
||||
categoryColor,
|
||||
@@ -301,8 +301,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
borderRadius: 16,
|
||||
blur: _isHovering ? 15 : 10,
|
||||
width: double.infinity, // 전체 너비를 차지하도록 설정
|
||||
onTap: widget.onTap ?? () async {
|
||||
print('[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
|
||||
onTap: widget.onTap ??
|
||||
() async {
|
||||
// ignore: use_build_context_synchronously
|
||||
await AppNavigator.toDetail(context, widget.subscription);
|
||||
},
|
||||
child: Column(
|
||||
@@ -349,17 +350,18 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 서비스명
|
||||
Flexible(
|
||||
child: Text(
|
||||
_displayName ?? widget.subscription.serviceName,
|
||||
_displayName ??
|
||||
widget.subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 메인 텍스트
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -371,7 +373,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 이벤트 배지
|
||||
if (widget.subscription.isCurrentlyInEvent) ...[
|
||||
if (widget
|
||||
.subscription.isCurrentlyInEvent) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
@@ -384,8 +387,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
Color(0xFFFF8787),
|
||||
],
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -418,19 +420,21 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).getBillingCycleName(widget.subscription.billingCycle),
|
||||
AppLocalizations.of(context)
|
||||
.getBillingCycleName(
|
||||
widget.subscription.billingCycle),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
color: AppColors
|
||||
.navyGray, // color.md 가이드: 서브 텍스트
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -443,8 +447,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
|
||||
// 가격 정보
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 가격 표시 (이벤트 가격 반영)
|
||||
// 가격 표시 (언어별 통화)
|
||||
@@ -455,7 +458,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
if (widget.subscription.isCurrentlyInEvent && snapshot.data!.contains('|')) {
|
||||
if (widget.subscription.isCurrentlyInEvent &&
|
||||
snapshot.data!.contains('|')) {
|
||||
final prices = snapshot.data!.split('|');
|
||||
return Row(
|
||||
children: [
|
||||
@@ -465,7 +469,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.navyGray,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
decoration:
|
||||
TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -485,7 +490,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.subscription.isCurrentlyInEvent
|
||||
color: widget
|
||||
.subscription.isCurrentlyInEvent
|
||||
? const Color(0xFFFF6B6B)
|
||||
: AppColors.primaryColor,
|
||||
),
|
||||
@@ -506,18 +512,15 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
.withValues(alpha: 0.1)
|
||||
: AppColors.successColor
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isNearBilling
|
||||
? Icons
|
||||
.access_time_filled_rounded
|
||||
: Icons
|
||||
.check_circle_rounded,
|
||||
? Icons.access_time_filled_rounded
|
||||
: Icons.check_circle_rounded,
|
||||
size: 12,
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
@@ -552,7 +555,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
||||
color: const Color(0xFFFF6B6B)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -566,7 +570,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
const SizedBox(width: 4),
|
||||
// 이벤트 절약액 표시 (언어별 통화)
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.formatEventSavingsWithLocale(
|
||||
future: CurrencyUtil
|
||||
.formatEventSavingsWithLocale(
|
||||
widget.subscription,
|
||||
localeProvider.locale.languageCode,
|
||||
),
|
||||
@@ -589,12 +594,17 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 이벤트 종료일까지 남은 일수
|
||||
if (widget.subscription.eventEndDate != null) ...[
|
||||
if (widget.subscription.eventEndDate !=
|
||||
null) ...[
|
||||
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(
|
||||
fontSize: 11,
|
||||
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
|
||||
color: AppColors
|
||||
.navyGray, // color.md 가이드: 서브 텍스트
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../services/subscription_url_matcher.dart';
|
||||
import './dialogs/delete_confirmation_dialog.dart';
|
||||
import './common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// 카테고리별로 구독 목록을 표시하는 위젯
|
||||
class SubscriptionListWidget extends StatelessWidget {
|
||||
@@ -46,12 +47,17 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
child: Consumer<CategoryProvider>(
|
||||
builder: (context, categoryProvider, child) {
|
||||
return CategoryHeaderWidget(
|
||||
categoryName: categoryProvider.getLocalizedCategoryName(context, category),
|
||||
categoryName: categoryProvider.getLocalizedCategoryName(
|
||||
context, category),
|
||||
subscriptionCount: subscriptions.length,
|
||||
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
|
||||
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
|
||||
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
|
||||
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
|
||||
totalCostUSD:
|
||||
_calculateTotalByCurrency(subscriptions, 'USD'),
|
||||
totalCostKRW:
|
||||
_calculateTotalByCurrency(subscriptions, 'KRW'),
|
||||
totalCostJPY:
|
||||
_calculateTotalByCurrency(subscriptions, 'JPY'),
|
||||
totalCostCNY:
|
||||
_calculateTotalByCurrency(subscriptions, 'CNY'),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -65,6 +71,8 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
cacheExtent: 500,
|
||||
prototypeItem: const SizedBox(height: 156),
|
||||
itemCount: subscriptions.length,
|
||||
itemBuilder: (context, subIndex) {
|
||||
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||
@@ -92,33 +100,46 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
child: StaggeredAnimationItem(
|
||||
index: subIndex,
|
||||
delay: const Duration(milliseconds: 50),
|
||||
child: RepaintBoundary(
|
||||
child: SwipeableSubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
keepAlive: true,
|
||||
onTap: () {
|
||||
print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
Log.d(
|
||||
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||
AppNavigator.toDetail(
|
||||
context, subscriptions[subIndex]);
|
||||
},
|
||||
onDelete: () async {
|
||||
// 현재 로케일에 맞는 서비스명 가져오기
|
||||
final localeProvider = Provider.of<LocaleProvider>(
|
||||
final localeProvider =
|
||||
Provider.of<LocaleProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final locale = localeProvider.locale.languageCode;
|
||||
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
||||
serviceName: subscriptions[subIndex].serviceName,
|
||||
final locale =
|
||||
localeProvider.locale.languageCode;
|
||||
final displayName =
|
||||
await SubscriptionUrlMatcher
|
||||
.getServiceDisplayName(
|
||||
serviceName:
|
||||
subscriptions[subIndex].serviceName,
|
||||
locale: locale,
|
||||
);
|
||||
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
final shouldDelete = await DeleteConfirmationDialog.show(
|
||||
if (!context.mounted) return;
|
||||
final shouldDelete =
|
||||
await DeleteConfirmationDialog.show(
|
||||
context: context,
|
||||
serviceName: displayName,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (shouldDelete && context.mounted) {
|
||||
if (shouldDelete) {
|
||||
// 사용자가 확인한 경우에만 삭제 진행
|
||||
final provider = Provider.of<SubscriptionProvider>(
|
||||
final provider =
|
||||
Provider.of<SubscriptionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
@@ -129,7 +150,8 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).subscriptionDeleted(displayName),
|
||||
message: AppLocalizations.of(context)
|
||||
.subscriptionDeleted(displayName),
|
||||
icon: Icons.delete_forever_rounded,
|
||||
);
|
||||
}
|
||||
@@ -138,6 +160,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -152,7 +175,8 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 특정 통화의 총 합계를 계산합니다.
|
||||
double _calculateTotalByCurrency(List<SubscriptionModel> subscriptions, String currency) {
|
||||
double _calculateTotalByCurrency(
|
||||
List<SubscriptionModel> subscriptions, String currency) {
|
||||
return subscriptions
|
||||
.where((sub) => sub.currency == currency)
|
||||
.fold(0.0, (sum, sub) => sum + sub.monthlyCost);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../utils/haptic_feedback_helper.dart';
|
||||
import 'subscription_card.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
final VoidCallback? onEdit;
|
||||
final Future<void> Function()? onDelete;
|
||||
final VoidCallback? onTap;
|
||||
final bool keepAlive;
|
||||
|
||||
const SwipeableSubscriptionCard({
|
||||
super.key,
|
||||
@@ -16,6 +17,7 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onTap,
|
||||
this.keepAlive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -24,12 +26,11 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
||||
// 상수 정의
|
||||
static const double _tapTolerance = 20.0; // 탭 허용 범위
|
||||
static const double _actionThresholdPercent = 0.15;
|
||||
static const double _deleteThresholdPercent = 0.40;
|
||||
static const int _tapDurationMs = 500;
|
||||
static const double _velocityThreshold = 800.0;
|
||||
// static const double _animationDuration = 300.0;
|
||||
|
||||
@@ -39,8 +40,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
|
||||
// 제스처 추적
|
||||
Offset? _startPosition;
|
||||
DateTime? _startTime;
|
||||
bool _isValidTap = true;
|
||||
// 제스처 관련 보조 변수(간소화)
|
||||
|
||||
// 상태 관리
|
||||
double _currentOffset = 0;
|
||||
@@ -52,7 +52,9 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = Tween<double>(
|
||||
@@ -95,8 +97,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
// 제스처 핸들러
|
||||
void _handlePanStart(DragStartDetails details) {
|
||||
_startPosition = details.localPosition;
|
||||
_startTime = DateTime.now();
|
||||
_isValidTap = true;
|
||||
_hapticTriggered = false;
|
||||
_controller.stop();
|
||||
}
|
||||
@@ -104,12 +104,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
void _handlePanUpdate(DragUpdateDetails details) {
|
||||
final currentPosition = details.localPosition;
|
||||
final delta = currentPosition.dx - _startPosition!.dx;
|
||||
final distance = (currentPosition - _startPosition!).distance;
|
||||
|
||||
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
|
||||
if (distance > _tapTolerance) {
|
||||
_isValidTap = false;
|
||||
}
|
||||
// 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거
|
||||
|
||||
// 카드 이동
|
||||
setState(() {
|
||||
@@ -129,14 +124,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
}
|
||||
|
||||
// 헬퍼 메서드
|
||||
void _processTap() {
|
||||
print('[SwipeableSubscriptionCard] _processTap 호출됨');
|
||||
if (widget.onTap != null) {
|
||||
print('[SwipeableSubscriptionCard] onTap 콜백 실행');
|
||||
widget.onTap!();
|
||||
}
|
||||
_animateToOffset(0);
|
||||
}
|
||||
// 탭 처리는 SubscriptionCard에서 수행
|
||||
|
||||
void _processSwipe(double velocity) {
|
||||
final extent = _currentOffset.abs();
|
||||
@@ -232,10 +220,14 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
right: isLeft ? 0 : 24,
|
||||
),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 200),
|
||||
opacity: showIcon ? 1.0 : 0.0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 200),
|
||||
scale: showIcon ? 1.0 : 0.5,
|
||||
child: Icon(
|
||||
isDeleteThreshold
|
||||
@@ -253,12 +245,14 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
return Transform.translate(
|
||||
offset: Offset(_currentOffset, 0),
|
||||
child: Transform.scale(
|
||||
scale: 1.0 - (_currentOffset.abs() / 2000),
|
||||
scale:
|
||||
ReduceMotion.platform() ? 1.0 : 1.0 - (_currentOffset.abs() / 2000),
|
||||
child: Transform.rotate(
|
||||
angle: _currentOffset / 2000,
|
||||
angle: ReduceMotion.platform() ? 0.0 : _currentOffset / 2000,
|
||||
child: SubscriptionCard(
|
||||
subscription: widget.subscription,
|
||||
onTap: widget.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함
|
||||
onTap: widget
|
||||
.onTap, // onTap 콜백을 전달하여 SubscriptionCard 내부에서도 탭 처리 가능하도록 함
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -267,6 +261,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
// 웹과 모바일 모두 동일한 스와이프 기능 제공
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -282,4 +277,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.keepAlive;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ class ThemedText extends StatelessWidget {
|
||||
});
|
||||
|
||||
/// 배경 밝기에 따른 텍스트 색상 결정
|
||||
static Color getContrastColor(BuildContext context, {
|
||||
static Color getContrastColor(
|
||||
BuildContext context, {
|
||||
bool forceLight = false,
|
||||
bool forceDark = false,
|
||||
}) {
|
||||
@@ -58,21 +59,22 @@ class ThemedText extends StatelessWidget {
|
||||
/// 글래스모피즘 컨텍스트인지 확인
|
||||
static bool _isGlassmorphicContext(BuildContext context) {
|
||||
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
|
||||
final glassmorphic = context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
|
||||
final glassmorphic =
|
||||
context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
|
||||
return glassmorphic != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = color ?? getContrastColor(
|
||||
final textColor = color ??
|
||||
getContrastColor(
|
||||
context,
|
||||
forceLight: forceLight,
|
||||
forceDark: forceDark,
|
||||
);
|
||||
|
||||
final finalColor = opacity != null
|
||||
? textColor.withValues(alpha: opacity!)
|
||||
: textColor;
|
||||
final finalColor =
|
||||
opacity != null ? textColor.withValues(alpha: opacity!) : textColor;
|
||||
|
||||
final defaultStyle = DefaultTextStyle.of(context).style;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
// 파비콘 캐시 관리 클래스
|
||||
class FaviconCache {
|
||||
@@ -104,8 +105,6 @@ class FaviconCache {
|
||||
|
||||
// 구글 파비콘 API 서비스
|
||||
class GoogleFaviconService {
|
||||
|
||||
|
||||
// 구글 파비콘 API URL 생성
|
||||
static String getFaviconUrl(String domain, int size) {
|
||||
final directUrl =
|
||||
@@ -137,7 +136,8 @@ class GoogleFaviconService {
|
||||
static String getBase64PlaceholderIcon(String serviceName, Color color) {
|
||||
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
|
||||
final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?';
|
||||
final colorHex = color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2);
|
||||
final colorHex =
|
||||
color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2);
|
||||
|
||||
// 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생)
|
||||
final svgContent =
|
||||
@@ -191,11 +191,14 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
// 애니메이션 컨트롤러 초기화
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
|
||||
CurvedAnimation(
|
||||
_scaleAnimation =
|
||||
Tween<double>(begin: 1.0, end: ReduceMotion.platform() ? 1.0 : 1.08)
|
||||
.animate(CurvedAnimation(
|
||||
parent: _animationController, curve: Curves.easeOutCubic));
|
||||
|
||||
// 초기 _previousServiceKey 설정
|
||||
@@ -549,11 +552,14 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
return RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
final scale =
|
||||
ReduceMotion.isEnabled(context) ? 1.0 : _scaleAnimation.value;
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
scale: scale,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
@@ -568,7 +574,8 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
boxShadow: widget.isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값
|
||||
color:
|
||||
_getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 4),
|
||||
@@ -578,12 +585,25 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
),
|
||||
child: _buildIconContent(),
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildIconContent() {
|
||||
// 로딩 중 표시
|
||||
if (_isLoading) {
|
||||
if (ReduceMotion.isEnabled(context)) {
|
||||
return Container(
|
||||
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
@@ -633,7 +653,17 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
fadeInDuration: ReduceMotion.isEnabled(context)
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
fadeOutDuration: ReduceMotion.isEnabled(context)
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
placeholder: (context, url) {
|
||||
if (ReduceMotion.isEnabled(context)) {
|
||||
return Container(color: AppColors.surfaceColorAlt);
|
||||
}
|
||||
return Container(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
@@ -646,7 +676,8 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => _buildFallbackIcon(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import webview_flutter_wkwebview
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin"))
|
||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user