3 Commits

112 changed files with 3582 additions and 2589 deletions

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

@@ -0,0 +1,13 @@
# Project Agent Handoff
Use AGENTS.md at repo root as the source of truth for coding rules and guardrails.
Key Rules
- Code first, concise rationale after. If uncertain, say "Uncertain:".
- Keep diffs minimal; follow existing patterns and `analysis_options.yaml`.
- Validate with `scripts/check.sh` (format/analyze/test) before completion.
- Ask for approval before dependency changes, build config edits, or network access.
Templates
- Task and PR templates are in `AGENTS.md` and `doc/agents/codex_prompt_templates.md`.

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

@@ -0,0 +1,31 @@
name: Flutter CI
on:
pull_request:
push:
branches: [ main, master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Flutter Pub Get
run: flutter pub get
- name: Format check
run: dart format --output=none --set-exit-if-changed .
- name: Analyze
run: flutter analyze
- name: Test
run: flutter test

69
AGENTS.md Normal file
View File

@@ -0,0 +1,69 @@
Codex Agent Guide for SubManager
Scope
- Applies to the entire repository unless a more specific rule exists deeper in the tree.
- Precedence: project AGENTS.md > project .claude/agents > user ~/.claude > default Codex CLI rules. Direct system/developer instructions always win.
Goals
- Accelerate small, safe changes with consistent quality.
- Keep diffs minimal, focused, and aligned with Flutter best practices.
Guardrails
- Workspace only: modify files within this repo. Ask before adding dependencies or using network.
- Safety: avoid destructive actions (file deletions, rewrites, config changes) unless explicitly requested.
- Responses: be concise; code first, short rationale after. If uncertain, prefix with "Uncertain:". If multiple viable solutions, show the top 2 briefly.
- Planning: for multistep tasks, maintain an update_plan with exactly one in_progress step.
Coding Standards
- Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline).
- Style/format: use `dart format .` and keep changes minimal. Avoid oneletter variable names; avoid inline comments unless requested.
- Structure: follow existing file/module patterns and naming. Do not introduce new frameworks or architectural shifts without approval.
- Tests: add or update tests when behavior changes or bugs are fixed (if feasible). Keep tests scoped to the change.
Validation
- Always run local checks via `scripts/check.sh` before proposing completion:
- formatting check: `dart format --set-exit-if-changed .`
- static analysis: `flutter analyze`
- unit/widget tests: `flutter test` (ok if none exist)
- UI changes: include brief description of visual impact; screenshots if readily available by the user.
Sensitive Areas (require explicit approval)
- Android/iOS/macOS build configs, signing, bundle identifiers, Gradle/Kotlin/Swift project settings.
- Dependency graph changes (pubspec.yaml add/remove/upgrade).
- Network access, calling external APIs, or adding secrets.
Operational Conventions
- Branch naming: `codex/<type>-<slug>` (e.g., `codex/fix-url-matcher`).
- Commits: Conventional Commits preferred (e.g., `fix: correct url matching for X`).
- PR description template:
- Summary: what/why
- Changes: key files and decisions
- Validation: how verified (analyze/tests/manual)
- Risk & Rollback: potential impact and quick rollback steps
Task Template (author-provided)
---
Next: <what to do>
Complexity: simple | medium | complex
Context
- Problem / goal:
- Constraints / nongoals:
- Repro or commands:
Done When
- [ ] Behavior verified (`scripts/check.sh` passes)
- [ ] Tests/docs updated if applicable
---
Commands
- Lint/analyze/tests: `scripts/check.sh`
- Autoformat: `scripts/fix.sh`
References & External Facts
- Prefer official docs and codelocal references. If citing sources, include plain URLs or file paths in PR descriptions (avoid footnote citation syntaxes).
Notes from ~/.claude (adapted)
- Fewshot examples improve accuracy; include small before/after or sample input→output when helpful.
- Use structured thinking internally; present only concise, actionable outputs here.

View File

@@ -33,6 +33,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

View File

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

View File

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

View File

@@ -13,29 +13,29 @@ import '../l10n/app_localizations.dart';
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController {
final BuildContext context;
// Form Key
final formKey = GlobalKey<FormState>();
// Text Controllers
final serviceNameController = TextEditingController();
final monthlyCostController = TextEditingController();
final nextBillingDateController = TextEditingController();
final websiteUrlController = TextEditingController();
final eventPriceController = TextEditingController();
// Form State
String billingCycle = 'monthly';
String currency = 'KRW';
DateTime? nextBillingDate;
bool isLoading = false;
String? selectedCategoryId;
// Event State
bool isEventActive = false;
DateTime? eventStartDate = DateTime.now();
DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30));
// Focus Nodes
final serviceNameFocus = FocusNode();
final monthlyCostFocus = FocusNode();
@@ -44,20 +44,20 @@ class AddSubscriptionController {
final websiteUrlFocus = FocusNode();
final categoryFocus = FocusNode();
final currencyFocus = FocusNode();
// Animation Controller
AnimationController? animationController;
Animation<double>? fadeAnimation;
Animation<Offset>? slideAnimation;
// Scroll Controller
final ScrollController scrollController = ScrollController();
double scrollOffset = 0;
// UI State
int currentEditingField = -1;
bool isSaveHovered = false;
// Gradient Colors
final List<Color> gradientColors = [
const Color(0xFF3B82F6),
@@ -71,19 +71,19 @@ class AddSubscriptionController {
void initialize({required TickerProvider vsync}) {
// 결제일 기본값을 오늘 날짜로 설정
nextBillingDate = DateTime.now();
// 서비스명 컨트롤러에 리스너 추가
serviceNameController.addListener(onServiceNameChanged);
// 웹사이트 URL 컨트롤러에 리스너 추가
websiteUrlController.addListener(onWebsiteUrlChanged);
// 애니메이션 컨트롤러 초기화
animationController = AnimationController(
vsync: vsync,
duration: const Duration(milliseconds: 800),
);
fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -91,7 +91,7 @@ class AddSubscriptionController {
parent: animationController!,
curve: Curves.easeIn,
));
slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.2),
end: Offset.zero,
@@ -99,12 +99,12 @@ class AddSubscriptionController {
parent: animationController!,
curve: Curves.easeOut,
));
// 스크롤 리스너
scrollController.addListener(() {
scrollOffset = scrollController.offset;
});
// 애니메이션 시작
animationController!.forward();
}
@@ -117,7 +117,7 @@ class AddSubscriptionController {
nextBillingDateController.dispose();
websiteUrlController.dispose();
eventPriceController.dispose();
// Focus Nodes
serviceNameFocus.dispose();
monthlyCostFocus.dispose();
@@ -126,10 +126,10 @@ class AddSubscriptionController {
websiteUrlFocus.dispose();
categoryFocus.dispose();
currencyFocus.dispose();
// Animation
animationController?.dispose();
// Scroll
scrollController.dispose();
}
@@ -138,43 +138,46 @@ class AddSubscriptionController {
void onServiceNameChanged() {
autoSelectCategory();
}
/// 웹사이트 URL 변경시 호출
void onWebsiteUrlChanged() async {
final url = websiteUrlController.text.trim();
// URL이 비어있거나 너무 짧으면 무시
if (url.isEmpty || url.length < 5) return;
// 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음
if (serviceNameController.text.isNotEmpty) return;
try {
// URL로 서비스 정보 찾기
final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url);
if (serviceInfo != null && context.mounted) {
// 서비스명 자동 입력
serviceNameController.text = serviceInfo.serviceName;
// 카테고리 자동 선택
final categoryProvider = Provider.of<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.name == serviceInfo.categoryNameEn,
(cat) =>
cat.name == serviceInfo.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn,
orElse: () => categories.first,
);
selectedCategoryId = matchedCategory.id;
// 스낵바로 알림
if (context.mounted) {
AppSnackBar.showSuccess(
context: context,
message: AppLocalizations.of(context).serviceRecognized(serviceInfo.serviceName),
message: AppLocalizations.of(context)
.serviceRecognized(serviceInfo.serviceName),
);
}
}
@@ -187,17 +190,18 @@ 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();
// 서비스명에 기반한 카테고리 매칭 로직
dynamic matchedCategory;
// 엔터테인먼트 관련 키워드
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓챠') ||
serviceName.contains('티빙') ||
@@ -210,64 +214,64 @@ class AddSubscriptionController {
);
}
// 음악 관련 키워드
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
);
}
// 생산성 관련 키워드
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '생산성',
orElse: () => categories.first,
);
}
// 게임 관련 키워드
else if (serviceName.contains('xbox') ||
serviceName.contains('playstation') ||
serviceName.contains('nintendo') ||
serviceName.contains('steam') ||
serviceName.contains('게임')) {
else if (serviceName.contains('xbox') ||
serviceName.contains('playstation') ||
serviceName.contains('nintendo') ||
serviceName.contains('steam') ||
serviceName.contains('게임')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '게임',
orElse: () => categories.first,
);
}
// 교육 관련 키워드
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '교육',
orElse: () => categories.first,
);
}
// 쇼핑 관련 키워드
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '쇼핑',
orElse: () => categories.first,
);
}
if (matchedCategory != null) {
selectedCategoryId = matchedCategory.id;
}
@@ -276,9 +280,9 @@ class AddSubscriptionController {
/// SMS 스캔
Future<void> scanSMS({required Function setState}) async {
if (kIsWeb) return;
setState(() => isLoading = true);
try {
if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission();
@@ -292,7 +296,7 @@ class AddSubscriptionController {
return;
}
}
final subscriptions = await SMSService.scanSubscriptions();
if (subscriptions.isEmpty) {
if (context.mounted) {
@@ -303,48 +307,51 @@ class AddSubscriptionController {
}
return;
}
final subscription = subscriptions.first;
// SMS에서 서비스 정보 추출 시도
ServiceInfo? serviceInfo;
final smsContent = subscription['smsContent'] ?? '';
if (smsContent.isNotEmpty) {
try {
serviceInfo = await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
serviceInfo =
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
} catch (e) {
if (kDebugMode) {
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
}
}
}
setState(() {
// 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용
if (serviceInfo != null) {
serviceNameController.text = serviceInfo.serviceName;
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.name == serviceInfo.categoryNameEn,
(cat) =>
cat.name == serviceInfo!.categoryNameKr ||
cat.name == serviceInfo.categoryNameEn,
orElse: () => categories.first,
);
selectedCategoryId = matchedCategory.id;
} else {
// 기존 로직 사용
serviceNameController.text = subscription['serviceName'] ?? '';
}
// 비용 처리 및 통화 단위 자동 감지
final costValue = subscription['monthlyCost']?.toString() ?? '';
if (costValue.isNotEmpty) {
// 달러 표시가 있거나 소수점이 있으면 달러로 판단
if (costValue.contains('\$') || costValue.contains('.')) {
@@ -353,41 +360,41 @@ class AddSubscriptionController {
if (!numericValue.contains('.')) {
numericValue = '$numericValue.00';
}
final double parsedValue =
final double parsedValue =
double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0;
monthlyCostController.text =
monthlyCostController.text =
NumberFormat('#,##0.00').format(parsedValue);
} else {
currency = 'KRW';
String numericValue =
String numericValue =
costValue.replaceAll('', '').replaceAll(',', '').trim();
final int parsedValue = int.tryParse(numericValue) ?? 0;
monthlyCostController.text =
monthlyCostController.text =
NumberFormat.decimalPattern().format(parsedValue);
}
} else {
monthlyCostController.text = '';
}
billingCycle = subscription['billingCycle'] ?? '월간';
nextBillingDate = subscription['nextBillingDate'] != null
? DateTime.parse(subscription['nextBillingDate'])
: DateTime.now();
// 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도
if (serviceInfo == null &&
subscription['serviceName'] != null &&
if (serviceInfo == null &&
subscription['serviceName'] != null &&
subscription['serviceName'].isNotEmpty) {
final suggestedUrl =
final suggestedUrl =
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
if (suggestedUrl != null && websiteUrlController.text.isEmpty) {
websiteUrlController.text = suggestedUrl;
}
// 서비스명 기반으로 카테고리 자동 선택
autoSelectCategory();
}
// 애니메이션 재생
animationController!.reset();
animationController!.forward();
@@ -396,7 +403,8 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()),
message: AppLocalizations.of(context)
.smsScanErrorWithMessage(e.toString()),
);
}
} finally {
@@ -412,20 +420,19 @@ class AddSubscriptionController {
setState(() {
isLoading = true;
});
try {
// 콤마 제거하고 숫자만 추출
final monthlyCost =
final monthlyCost =
double.parse(monthlyCostController.text.replaceAll(',', ''));
// 이벤트 가격 파싱
double? eventPrice;
if (isEventActive && eventPriceController.text.isNotEmpty) {
eventPrice = double.tryParse(
eventPriceController.text.replaceAll(',', '')
);
eventPrice =
double.tryParse(eventPriceController.text.replaceAll(',', ''));
}
await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription(
serviceName: serviceNameController.text.trim(),
@@ -440,7 +447,7 @@ class AddSubscriptionController {
eventEndDate: eventEndDate,
eventPrice: eventPrice,
);
if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환
}
@@ -448,11 +455,12 @@ class AddSubscriptionController {
setState(() {
isLoading = false;
});
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
message:
AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
);
}
}
@@ -464,4 +472,4 @@ class AddSubscriptionController {
);
}
}
}
}

View File

@@ -17,17 +17,17 @@ import '../l10n/app_localizations.dart';
class DetailScreenController extends ChangeNotifier {
final BuildContext context;
final SubscriptionModel subscription;
// Text Controllers
late TextEditingController serviceNameController;
late TextEditingController monthlyCostController;
late TextEditingController websiteUrlController;
late TextEditingController eventPriceController;
// Display Names
String? _displayName;
String? get displayName => _displayName;
// Form State
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
late String _billingCycle;
@@ -35,12 +35,12 @@ class DetailScreenController extends ChangeNotifier {
String? _selectedCategoryId;
late String _currency;
bool _isLoading = false;
// Event State
late bool _isEventActive;
DateTime? _eventStartDate;
DateTime? _eventEndDate;
// Getters
String get billingCycle => _billingCycle;
DateTime get nextBillingDate => _nextBillingDate;
@@ -50,7 +50,7 @@ class DetailScreenController extends ChangeNotifier {
bool get isEventActive => _isEventActive;
DateTime? get eventStartDate => _eventStartDate;
DateTime? get eventEndDate => _eventEndDate;
// Setters
set billingCycle(String value) {
if (_billingCycle != value) {
@@ -58,21 +58,21 @@ class DetailScreenController extends ChangeNotifier {
notifyListeners();
}
}
set nextBillingDate(DateTime value) {
if (_nextBillingDate != value) {
_nextBillingDate = value;
notifyListeners();
}
}
set selectedCategoryId(String? value) {
if (_selectedCategoryId != value) {
_selectedCategoryId = value;
notifyListeners();
}
}
set currency(String value) {
if (_currency != value) {
_currency = value;
@@ -80,35 +80,35 @@ class DetailScreenController extends ChangeNotifier {
notifyListeners();
}
}
set isLoading(bool value) {
if (_isLoading != value) {
_isLoading = value;
notifyListeners();
}
}
set isEventActive(bool value) {
if (_isEventActive != value) {
_isEventActive = value;
notifyListeners();
}
}
set eventStartDate(DateTime? value) {
if (_eventStartDate != value) {
_eventStartDate = value;
notifyListeners();
}
}
set eventEndDate(DateTime? value) {
if (_eventEndDate != value) {
_eventEndDate = value;
notifyListeners();
}
}
// Focus Nodes
final serviceNameFocus = FocusNode();
final monthlyCostFocus = FocusNode();
@@ -117,7 +117,7 @@ class DetailScreenController extends ChangeNotifier {
final websiteUrlFocus = FocusNode();
final categoryFocus = FocusNode();
final currencyFocus = FocusNode();
// UI State
final ScrollController scrollController = ScrollController();
double scrollOffset = 0;
@@ -125,7 +125,7 @@ class DetailScreenController extends ChangeNotifier {
bool isDeleteHovered = false;
bool isSaveHovered = false;
bool isCancelHovered = false;
// Animation Controller
AnimationController? animationController;
Animation<double>? fadeAnimation;
@@ -140,42 +140,45 @@ class DetailScreenController extends ChangeNotifier {
/// 초기화
void initialize({required TickerProvider vsync}) {
// Text Controllers 초기화
serviceNameController = TextEditingController(text: subscription.serviceName);
monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString());
websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? '');
serviceNameController =
TextEditingController(text: subscription.serviceName);
monthlyCostController =
TextEditingController(text: subscription.monthlyCost.toString());
websiteUrlController =
TextEditingController(text: subscription.websiteUrl ?? '');
eventPriceController = TextEditingController();
// Form State 초기화
_billingCycle = subscription.billingCycle;
_nextBillingDate = subscription.nextBillingDate;
_selectedCategoryId = subscription.categoryId;
_currency = subscription.currency;
// Event State 초기화
_isEventActive = subscription.isEventActive;
_eventStartDate = subscription.eventStartDate;
_eventEndDate = subscription.eventEndDate;
// 이벤트 가격 초기화
if (subscription.eventPrice != null) {
if (currency == 'KRW') {
eventPriceController.text = NumberFormat.decimalPattern()
.format(subscription.eventPrice!.toInt());
} else {
eventPriceController.text =
eventPriceController.text =
NumberFormat('#,##0.00').format(subscription.eventPrice!);
}
}
// 통화 단위에 따른 금액 표시 형식 조정
_updateMonthlyCostFormat();
// 애니메이션 초기화
animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: vsync,
);
fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -183,7 +186,7 @@ class DetailScreenController extends ChangeNotifier {
parent: animationController!,
curve: Curves.easeInOut,
));
slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.3),
end: Offset.zero,
@@ -191,7 +194,7 @@ class DetailScreenController extends ChangeNotifier {
parent: animationController!,
curve: Curves.easeOutCubic,
));
rotateAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
@@ -199,16 +202,16 @@ class DetailScreenController extends ChangeNotifier {
parent: animationController!,
curve: Curves.easeOutCubic,
));
// 애니메이션 시작
animationController!.forward();
// 로케일에 맞는 서비스명 로드
_loadDisplayName();
// 서비스명 변경 감지 리스너
serviceNameController.addListener(onServiceNameChanged);
// 스크롤 리스너
scrollController.addListener(() {
scrollOffset = scrollController.offset;
@@ -219,16 +222,16 @@ class DetailScreenController extends ChangeNotifier {
Future<void> _loadDisplayName() async {
final localeProvider = context.read<LocaleProvider>();
final locale = localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscription.serviceName,
locale: locale,
);
_displayName = displayName;
notifyListeners();
}
/// 리소스 정리
@override
void dispose() {
@@ -237,7 +240,7 @@ class DetailScreenController extends ChangeNotifier {
monthlyCostController.dispose();
websiteUrlController.dispose();
eventPriceController.dispose();
// Focus Nodes
serviceNameFocus.dispose();
monthlyCostFocus.dispose();
@@ -246,13 +249,13 @@ class DetailScreenController extends ChangeNotifier {
websiteUrlFocus.dispose();
categoryFocus.dispose();
currencyFocus.dispose();
// Animation
animationController?.dispose();
// Scroll
scrollController.dispose();
super.dispose();
}
@@ -261,10 +264,12 @@ class DetailScreenController extends ChangeNotifier {
if (_currency == 'KRW') {
// 원화는 소수점 없이 표시
final intValue = subscription.monthlyCost.toInt();
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
monthlyCostController.text =
NumberFormat.decimalPattern().format(intValue);
} else {
// 달러는 소수점 2자리까지 표시
monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost);
monthlyCostController.text =
NumberFormat('#,##0.00').format(subscription.monthlyCost);
}
}
@@ -275,17 +280,18 @@ class DetailScreenController extends ChangeNotifier {
/// 카테고리 자동 선택
void autoSelectCategory() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories;
final serviceName = serviceNameController.text.toLowerCase();
// 서비스명에 기반한 카테고리 매칭 로직
CategoryModel? matchedCategory;
// 엔터테인먼트 관련 키워드
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓챠') ||
serviceName.contains('티빙') ||
@@ -298,64 +304,64 @@ class DetailScreenController extends ChangeNotifier {
);
}
// 음악 관련 키워드
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
);
}
// 생산성 관련 키워드
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
);
}
// AI 관련 키워드
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('gemini') ||
serviceName.contains('copilot') ||
serviceName.contains('midjourney')) {
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('gemini') ||
serviceName.contains('copilot') ||
serviceName.contains('midjourney')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
);
}
// 교육 관련 키워드
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'programming',
orElse: () => categories.first,
);
}
// 쇼핑 관련 키워드
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categories.first,
);
}
if (matchedCategory != null) {
selectedCategoryId = matchedCategory.id;
}
@@ -371,30 +377,32 @@ class DetailScreenController extends ChangeNotifier {
);
return;
}
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
String? websiteUrl = websiteUrlController.text;
if (websiteUrl.isEmpty) {
websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
}
// 구독 정보 업데이트
// 콤마 제거하고 숫자만 추출
double monthlyCost = 0.0;
try {
monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', ''));
monthlyCost =
double.parse(monthlyCostController.text.replaceAll(',', ''));
} catch (e) {
// 파싱 오류 발생 시 기본값 사용
monthlyCost = subscription.monthlyCost;
}
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, '
'금액: ${subscription.monthlyCost}$monthlyCost ${_currency}');
subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl;
@@ -402,16 +410,16 @@ class DetailScreenController extends ChangeNotifier {
subscription.nextBillingDate = _nextBillingDate;
subscription.categoryId = _selectedCategoryId;
subscription.currency = _currency;
// 이벤트 정보 업데이트
subscription.isEventActive = _isEventActive;
subscription.eventStartDate = _eventStartDate;
subscription.eventEndDate = _eventEndDate;
// 이벤트 가격 파싱
if (_isEventActive && eventPriceController.text.isNotEmpty) {
try {
subscription.eventPrice =
subscription.eventPrice =
double.parse(eventPriceController.text.replaceAll(',', ''));
} catch (e) {
subscription.eventPrice = null;
@@ -419,20 +427,20 @@ class DetailScreenController extends ChangeNotifier {
} else {
subscription.eventPrice = null;
}
debugPrint('[DetailScreenController] 업데이트 정보: '
'현재가격=${subscription.currentPrice}, '
'이벤트활성=${subscription.isEventActive}');
// 구독 업데이트
await provider.updateSubscription(subscription);
if (context.mounted) {
AppSnackBar.showSuccess(
context: context,
message: AppLocalizations.of(context).subscriptionUpdated,
);
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
await Future.delayed(const Duration(milliseconds: 100));
if (context.mounted) {
@@ -445,30 +453,33 @@ 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,
);
// 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
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();
@@ -482,19 +493,22 @@ class DetailScreenController extends ChangeNotifier {
try {
// 1. 현재 언어 설정 가져오기
final locale = Localizations.localeOf(context).languageCode;
// 2. 해지 안내 URL 찾기
String? cancellationUrl = await SubscriptionUrlMatcher.findCancellationUrl(
String? cancellationUrl =
await SubscriptionUrlMatcher.findCancellationUrl(
serviceName: subscription.serviceName,
websiteUrl: subscription.websiteUrl,
locale: locale == 'ko' ? 'kr' : 'en',
);
// 3. 해지 안내 URL이 없으면 구글 검색
if (cancellationUrl == null) {
final searchQuery = '${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
cancellationUrl = 'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
final searchQuery =
'${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
cancellationUrl =
'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
@@ -502,7 +516,7 @@ class DetailScreenController extends ChangeNotifier {
);
}
}
// 4. URL 열기
final Uri url = Uri.parse(cancellationUrl);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
@@ -517,9 +531,9 @@ class DetailScreenController extends ChangeNotifier {
if (kDebugMode) {
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
}
// 오류 발생시 일반 웹사이트로 폴백
if (subscription.websiteUrl != null &&
if (subscription.websiteUrl != null &&
subscription.websiteUrl!.isNotEmpty) {
final Uri url = Uri.parse(subscription.websiteUrl!);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
@@ -554,7 +568,7 @@ class DetailScreenController extends ChangeNotifier {
const Color(0xFF0EA5E9), // 하늘
const Color(0xFFEC4899), // 분홍
];
return colors[hash % colors.length];
}
@@ -569,4 +583,4 @@ class DetailScreenController extends ChangeNotifier {
end: Alignment.bottomRight,
);
}
}
}

View File

@@ -59,11 +59,13 @@ class SmsScanController extends ChangeNotifier {
try {
// SMS 스캔 실행
print('SMS 스캔 시작');
final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions();
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
print('첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
print(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!context.mounted) return;
@@ -77,14 +79,17 @@ class SmsScanController extends ChangeNotifier {
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels);
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2);
final repeatSubscriptions =
_filter.filterByRepeatCount(scannedSubscriptions, 2);
print('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
print('첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
print(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
@@ -96,16 +101,19 @@ 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}');
// 중복 구독 필터링
final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
final filteredSubscriptions =
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) {
print('첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
print(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
// 중복 제거 후 신규 구독이 없는 경우
@@ -123,7 +131,8 @@ class SmsScanController extends ChangeNotifier {
} catch (e) {
print('SMS 스캔 중 오류 발생: $e');
if (context.mounted) {
_errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_errorMessage =
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_isLoading = false;
notifyListeners();
}
@@ -134,20 +143,25 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex];
try {
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider);
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ??
subscription.category ??
getDefaultCategoryId(categoryProvider);
// websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim()
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim()
: subscription.websiteUrl;
print('구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
print(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출
await provider.addSubscription(
serviceName: subscription.serviceName,
@@ -161,9 +175,9 @@ class SmsScanController extends ChangeNotifier {
categoryId: finalCategoryId,
currency: subscription.currency,
);
print('구독 추가 성공: ${subscription.serviceName}');
moveToNextSubscription(context);
} catch (e) {
print('구독 추가 중 오류 발생: $e');
@@ -187,13 +201,14 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) {
navigateToHome(context);
}
notifyListeners();
}
void navigateToHome(BuildContext context) {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
}
@@ -221,4 +236,4 @@ class SmsScanController extends ChangeNotifier {
}
}
}
}
}

View File

@@ -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,12 +54,9 @@ 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';
@@ -71,13 +67,15 @@ class AppLocalizations {
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 +83,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 +103,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,127 +114,198 @@ class AppLocalizations {
String get japanese => _localizedStrings['japanese'] ?? '日本語';
String get chinese => _localizedStrings['chinese'] ?? '中文';
// 날짜
String get oneDayBefore => _localizedStrings['oneDayBefore'] ?? '1 day before';
String get twoDaysBefore => _localizedStrings['twoDaysBefore'] ?? '2 days before';
String get threeDaysBefore => _localizedStrings['threeDaysBefore'] ?? '3 days before';
String get oneDayBefore =>
_localizedStrings['oneDayBefore'] ?? '1 day before';
String get twoDaysBefore =>
_localizedStrings['twoDaysBefore'] ?? '2 days before';
String get threeDaysBefore =>
_localizedStrings['threeDaysBefore'] ?? '3 days before';
// 추가 메시지
String get requiredFieldsError => _localizedStrings['requiredFieldsError'] ?? 'Please fill in all required fields';
String get subscriptionUpdated => _localizedStrings['subscriptionUpdated'] ?? 'Subscription information has been updated';
String get officialCancelPageNotFound => _localizedStrings['officialCancelPageNotFound'] ?? 'Official cancellation page not found. Redirecting to Google search.';
String get cannotOpenWebsite => _localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
String get noWebsiteInfo => _localizedStrings['noWebsiteInfo'] ?? 'No website information available. Please cancel through the website.';
String get requiredFieldsError =>
_localizedStrings['requiredFieldsError'] ??
'Please fill in all required fields';
String get subscriptionUpdated =>
_localizedStrings['subscriptionUpdated'] ??
'Subscription information has been updated';
String get officialCancelPageNotFound =>
_localizedStrings['officialCancelPageNotFound'] ??
'Official cancellation page not found. Redirecting to Google search.';
String get cannotOpenWebsite =>
_localizedStrings['cannotOpenWebsite'] ?? 'Cannot open website';
String get noWebsiteInfo =>
_localizedStrings['noWebsiteInfo'] ??
'No website information available. Please cancel through the website.';
String get editMode => _localizedStrings['editMode'] ?? 'Edit Mode';
String get changesAppliedAfterSave => _localizedStrings['changesAppliedAfterSave'] ?? 'Changes will be applied after saving';
String get changesAppliedAfterSave =>
_localizedStrings['changesAppliedAfterSave'] ??
'Changes will be applied after saving';
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
String get monthlyExpense => _localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
String get monthlyExpense =>
_localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
String get websiteUrlOptional => _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
String get websiteUrlOptional =>
_localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';
String get eventPrice => _localizedStrings['eventPrice'] ?? 'Event Price';
String get eventPriceHint => _localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
String get eventPriceRequired => _localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
String get invalidPrice => _localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
String get eventPriceHint =>
_localizedStrings['eventPriceHint'] ?? 'Enter discounted price';
String get eventPriceRequired =>
_localizedStrings['eventPriceRequired'] ?? 'Please enter event price';
String get invalidPrice =>
_localizedStrings['invalidPrice'] ?? 'Please enter a valid price';
String get smsScanLabel => _localizedStrings['smsScanLabel'] ?? 'SMS';
String get home => _localizedStrings['home'] ?? 'Home';
String get analysis => _localizedStrings['analysis'] ?? 'Analysis';
String get back => _localizedStrings['back'] ?? 'Back';
String get exitApp => _localizedStrings['exitApp'] ?? 'Exit App';
String get exitAppConfirm => _localizedStrings['exitAppConfirm'] ?? 'Are you sure you want to exit SubManager?';
String get exitAppConfirm =>
_localizedStrings['exitAppConfirm'] ??
'Are you sure you want to exit SubManager?';
String get exit => _localizedStrings['exit'] ?? 'Exit';
String get pageNotFound => _localizedStrings['pageNotFound'] ?? 'Page not found';
String get serviceNameExample => _localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
String get urlExample => _localizedStrings['urlExample'] ?? 'https://example.com';
String get appLockDesc => _localizedStrings['appLockDesc'] ?? 'App lock with biometric authentication';
String get unlockWithBiometric => _localizedStrings['unlockWithBiometric'] ?? 'Unlock with biometric authentication';
String get authenticationFailed => _localizedStrings['authenticationFailed'] ?? 'Authentication failed. Please try again.';
String get smsPermissionRequired => _localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound => _localizedStrings['noSubscriptionSmsFound'] ?? 'No subscription related SMS found';
String get smsScanError => _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
String get saveError => _localizedStrings['saveError'] ?? 'Error occurred while saving';
String get newSubscriptionSmsNotFound => _localizedStrings['newSubscriptionSmsNotFound'] ?? 'No new subscription SMS found';
String get subscriptionAddError => _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
String get allSubscriptionsProcessed => _localizedStrings['allSubscriptionsProcessed'] ?? 'All subscriptions have been processed.';
String get websiteUrlExtracted => _localizedStrings['websiteUrlExtracted'] ?? 'Website URL (Auto-extracted)';
String get pageNotFound =>
_localizedStrings['pageNotFound'] ?? 'Page not found';
String get serviceNameExample =>
_localizedStrings['serviceNameExample'] ?? 'e.g. Netflix, Spotify';
String get urlExample =>
_localizedStrings['urlExample'] ?? 'https://example.com';
String get appLockDesc =>
_localizedStrings['appLockDesc'] ??
'App lock with biometric authentication';
String get unlockWithBiometric =>
_localizedStrings['unlockWithBiometric'] ??
'Unlock with biometric authentication';
String get authenticationFailed =>
_localizedStrings['authenticationFailed'] ??
'Authentication failed. Please try again.';
String get smsPermissionRequired =>
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound =>
_localizedStrings['noSubscriptionSmsFound'] ??
'No subscription related SMS found';
String get smsScanError =>
_localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan';
String get saveError =>
_localizedStrings['saveError'] ?? 'Error occurred while saving';
String get newSubscriptionSmsNotFound =>
_localizedStrings['newSubscriptionSmsNotFound'] ??
'No new subscription SMS found';
String get subscriptionAddError =>
_localizedStrings['subscriptionAddError'] ?? 'Error adding subscription';
String get allSubscriptionsProcessed =>
_localizedStrings['allSubscriptionsProcessed'] ??
'All subscriptions have been processed.';
String get websiteUrlExtracted =>
_localizedStrings['websiteUrlExtracted'] ??
'Website URL (Auto-extracted)';
String get startDate => _localizedStrings['startDate'] ?? 'Start Date';
String get endDate => _localizedStrings['endDate'] ?? 'End Date';
// 새로 추가된 항목들
String get monthlyTotalSubscriptionCost => _localizedStrings['monthlyTotalSubscriptionCost'] ?? 'Total Monthly Subscription Cost';
String get todaysExchangeRate => _localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
String get monthlyTotalSubscriptionCost =>
_localizedStrings['monthlyTotalSubscriptionCost'] ??
'Total Monthly Subscription Cost';
String get todaysExchangeRate =>
_localizedStrings['todaysExchangeRate'] ?? 'Today\'s Exchange Rate';
String get won => _localizedStrings['won'] ?? 'KRW';
String get estimatedAnnualCost => _localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
String get totalSubscriptionServices => _localizedStrings['totalSubscriptionServices'] ?? 'Total Subscription Services';
String get estimatedAnnualCost =>
_localizedStrings['estimatedAnnualCost'] ?? 'Estimated Annual Cost';
String get totalSubscriptionServices =>
_localizedStrings['totalSubscriptionServices'] ??
'Total Subscription Services';
String get services => _localizedStrings['services'] ?? 'services';
String get eventDiscountActive => _localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
String get eventDiscountActive =>
_localizedStrings['eventDiscountActive'] ?? 'Event Discount Active';
String get saving => _localizedStrings['saving'] ?? 'Saving';
String get paymentDueToday => _localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
String get paymentInfoNeeded => _localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
String get paymentDueToday =>
_localizedStrings['paymentDueToday'] ?? 'Payment Due Today';
String get paymentInfoNeeded =>
_localizedStrings['paymentInfoNeeded'] ?? 'Payment Info Needed';
String get event => _localizedStrings['event'] ?? 'Event';
// 카테고리 getter들
String get categoryMusic => _localizedStrings['categoryMusic'] ?? 'Music';
String get categoryOttVideo => _localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
String get categoryStorageCloud => _localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
String get categoryTelecomInternetTv => _localizedStrings['categoryTelecomInternetTv'] ?? 'Telecom · Internet · TV';
String get categoryLifestyle => _localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
String get categoryShoppingEcommerce => _localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
String get categoryProgramming => _localizedStrings['categoryProgramming'] ?? 'Programming';
String get categoryCollaborationOffice => _localizedStrings['categoryCollaborationOffice'] ?? 'Collaboration/Office';
String get categoryAiService => _localizedStrings['categoryAiService'] ?? 'AI Service';
String get categoryOttVideo =>
_localizedStrings['categoryOttVideo'] ?? 'OTT(Video)';
String get categoryStorageCloud =>
_localizedStrings['categoryStorageCloud'] ?? 'Storage/Cloud';
String get categoryTelecomInternetTv =>
_localizedStrings['categoryTelecomInternetTv'] ??
'Telecom · Internet · TV';
String get categoryLifestyle =>
_localizedStrings['categoryLifestyle'] ?? 'Lifestyle';
String get categoryShoppingEcommerce =>
_localizedStrings['categoryShoppingEcommerce'] ?? 'Shopping/E-commerce';
String get categoryProgramming =>
_localizedStrings['categoryProgramming'] ?? 'Programming';
String get categoryCollaborationOffice =>
_localizedStrings['categoryCollaborationOffice'] ??
'Collaboration/Office';
String get categoryAiService =>
_localizedStrings['categoryAiService'] ?? 'AI Service';
String get categoryOther => _localizedStrings['categoryOther'] ?? 'Other';
// 동적 메시지 생성 메서드
String daysBefore(int days) {
return '$days${_localizedStrings['daysBefore'] ?? 'day(s) before'}';
}
String dailyReminderDisabledWithDays(int days) {
final template = _localizedStrings['dailyReminderDisabled'] ?? 'Receive notification @ day(s) before payment';
final template = _localizedStrings['dailyReminderDisabled'] ??
'Receive notification @ day(s) before payment';
return template.replaceAll('@', days.toString());
}
String subscriptionAddedWithName(String serviceName) {
final template = _localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
final template =
_localizedStrings['subscriptionAddedTemplate'] ?? '@ 구독이 추가되었습니다.';
return template.replaceAll('@', serviceName);
}
String subscriptionDeleted(String serviceName) {
final template = _localizedStrings['subscriptionDeleted'] ?? '@ subscription has been deleted';
final template = _localizedStrings['subscriptionDeleted'] ??
'@ subscription has been deleted';
return template.replaceAll('@', serviceName);
}
String totalExpenseCopied(String amount) {
final template = _localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
final template =
_localizedStrings['totalExpenseCopied'] ?? 'Total expense copied: @';
return template.replaceAll('@', amount);
}
String serviceRecognized(String serviceName) {
final template = _localizedStrings['serviceRecognized'] ?? '@ service has been recognized automatically.';
final template = _localizedStrings['serviceRecognized'] ??
'@ service has been recognized automatically.';
return template.replaceAll('@', serviceName);
}
String smsScanErrorWithMessage(String error) {
final template = _localizedStrings['smsScanError'] ?? 'Error occurred during SMS scan: @';
final template = _localizedStrings['smsScanError'] ??
'Error occurred during SMS scan: @';
return template.replaceAll('@', error);
}
String saveErrorWithMessage(String error) {
final template = _localizedStrings['saveError'] ?? 'Error occurred while saving: @';
final template =
_localizedStrings['saveError'] ?? 'Error occurred while saving: @';
return template.replaceAll('@', error);
}
String subscriptionAddErrorWithMessage(String error) {
final template = _localizedStrings['subscriptionAddError'] ?? 'Error adding subscription: @';
final template = _localizedStrings['subscriptionAddError'] ??
'Error adding subscription: @';
return template.replaceAll('@', error);
}
String subscriptionSkipped(String serviceName) {
final template = _localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
final template =
_localizedStrings['subscriptionSkipped'] ?? '@ subscription skipped.';
return template.replaceAll('@', serviceName);
}
// 홈화면 관련
String get mySubscriptions => _localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
String get mySubscriptions =>
_localizedStrings['mySubscriptions'] ?? 'My Subscriptions';
String subscriptionCount(int count) {
if (locale.languageCode == 'ko') {
return '${count}';
@@ -244,58 +317,99 @@ class AppLocalizations {
return count.toString();
}
}
// 분석화면 관련
String get monthlyExpenseTitle => _localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
String get recentSixMonthsTrend => _localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
String get monthlySubscriptionExpense => _localizedStrings['monthlySubscriptionExpense'] ?? 'Monthly subscription expense';
String get subscriptionServiceRatio => _localizedStrings['subscriptionServiceRatio'] ?? 'Subscription Service Ratio';
String get monthlyExpenseBasis => _localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
String get noSubscriptionServices => _localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
String get totalExpenseSummary => _localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
String get monthlyTotalAmount => _localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
String get totalExpense => _localizedStrings['totalExpense'] ?? 'Total Expense';
String get totalServices => _localizedStrings['totalServices'] ?? 'Total Services';
String get monthlyExpenseTitle =>
_localizedStrings['monthlyExpenseTitle'] ?? 'Monthly Expense Status';
String get recentSixMonthsTrend =>
_localizedStrings['recentSixMonthsTrend'] ?? 'Recent 6 months trend';
String get monthlySubscriptionExpense =>
_localizedStrings['monthlySubscriptionExpense'] ??
'Monthly subscription expense';
String get subscriptionServiceRatio =>
_localizedStrings['subscriptionServiceRatio'] ??
'Subscription Service Ratio';
String get monthlyExpenseBasis =>
_localizedStrings['monthlyExpenseBasis'] ?? 'Based on monthly expense';
String get noSubscriptionServices =>
_localizedStrings['noSubscriptionServices'] ?? 'No subscription services';
String get totalExpenseSummary =>
_localizedStrings['totalExpenseSummary'] ?? 'Total Expense Summary';
String get monthlyTotalAmount =>
_localizedStrings['monthlyTotalAmount'] ?? 'Monthly Total Amount';
String get totalExpense =>
_localizedStrings['totalExpense'] ?? 'Total Expense';
String get totalServices =>
_localizedStrings['totalServices'] ?? 'Total Services';
String get servicesUnit => _localizedStrings['servicesUnit'] ?? 'services';
String get averageCost => _localizedStrings['averageCost'] ?? 'Average Cost';
String get eventDiscountStatus => _localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get inProgressUnit => _localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount => _localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
String get eventsInProgress => _localizedStrings['eventsInProgress'] ?? 'Events in Progress';
String get discountPercent => _localizedStrings['discountPercent'] ?? '% discount';
String get eventDiscountStatus =>
_localizedStrings['eventDiscountStatus'] ?? 'Event Discount Status';
String get inProgressUnit =>
_localizedStrings['inProgressUnit'] ?? 'in progress';
String get monthlySavingAmount =>
_localizedStrings['monthlySavingAmount'] ?? 'Monthly Saving Amount';
String get eventsInProgress =>
_localizedStrings['eventsInProgress'] ?? 'Events in Progress';
String get discountPercent =>
_localizedStrings['discountPercent'] ?? '% discount';
String get currencyWon => _localizedStrings['currencyWon'] ?? 'KRW';
// SMS 스캔 관련
String get scanningMessages => _localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
String get findingSubscriptions => _localizedStrings['findingSubscriptions'] ?? 'Finding subscription services';
String get subscriptionNotFound => _localizedStrings['subscriptionNotFound'] ?? 'Subscription information not found.';
String get repeatSubscriptionNotFound => _localizedStrings['repeatSubscriptionNotFound'] ?? 'No repeated subscription information found.';
String get newSubscriptionNotFound => _localizedStrings['newSubscriptionNotFound'] ?? 'No new subscription SMS found';
String get findRepeatSubscriptions => _localizedStrings['findRepeatSubscriptions'] ?? 'Find subscriptions paid 2+ times';
String get scanTextMessages => _localizedStrings['scanTextMessages'] ?? 'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
String get startScanning => _localizedStrings['startScanning'] ?? 'Start Scanning';
String get foundSubscription => _localizedStrings['foundSubscription'] ?? 'Found subscription';
String get scanningMessages =>
_localizedStrings['scanningMessages'] ?? 'Scanning SMS messages...';
String get findingSubscriptions =>
_localizedStrings['findingSubscriptions'] ??
'Finding subscription services';
String get subscriptionNotFound =>
_localizedStrings['subscriptionNotFound'] ??
'Subscription information not found.';
String get repeatSubscriptionNotFound =>
_localizedStrings['repeatSubscriptionNotFound'] ??
'No repeated subscription information found.';
String get newSubscriptionNotFound =>
_localizedStrings['newSubscriptionNotFound'] ??
'No new subscription SMS found';
String get findRepeatSubscriptions =>
_localizedStrings['findRepeatSubscriptions'] ??
'Find subscriptions paid 2+ times';
String get scanTextMessages =>
_localizedStrings['scanTextMessages'] ??
'Scan text messages to automatically find repeatedly paid subscriptions. Service names and amounts can be extracted for easy subscription addition.';
String get startScanning =>
_localizedStrings['startScanning'] ?? 'Start Scanning';
String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get nextBillingDateLabel => _localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get nextBillingDateLabel =>
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category';
String get websiteUrlAuto => _localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
String get websiteUrlHint => _localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
String get websiteUrlAuto =>
_localizedStrings['websiteUrlAuto'] ?? 'Website URL (Auto-extracted)';
String get websiteUrlHint =>
_localizedStrings['websiteUrlHint'] ?? 'Edit website URL or leave empty';
String get skip => _localizedStrings['skip'] ?? 'Skip';
String get add => _localizedStrings['add'] ?? 'Add';
String get nextBillingDateRequired => _localizedStrings['nextBillingDateRequired'] ?? 'Next billing date verification required';
String get nextBillingDateRequired =>
_localizedStrings['nextBillingDateRequired'] ??
'Next billing date verification required';
String nextBillingDateEstimated(String date, int days) {
final template = _localizedStrings['nextBillingDateEstimated'] ?? 'Next estimated billing date: @ (# days later)';
final template = _localizedStrings['nextBillingDateEstimated'] ??
'Next estimated billing date: @ (# days later)';
return template.replaceAll('@', date).replaceAll('#', days.toString());
}
String nextBillingDateInfo(String date, int days) {
final template = _localizedStrings['nextBillingDateInfo'] ?? 'Next billing date: @ (# days later)';
final template = _localizedStrings['nextBillingDateInfo'] ??
'Next billing date: @ (# days later)';
return template.replaceAll('@', date).replaceAll('#', days.toString());
}
String get nextBillingDatePastRequired => _localizedStrings['nextBillingDatePastRequired'] ?? 'Next billing date verification required (past date)';
String get nextBillingDatePastRequired =>
_localizedStrings['nextBillingDatePastRequired'] ??
'Next billing date verification required (past date)';
String formatDate(DateTime date) {
if (locale.languageCode == 'ko') {
return '${date.year}${date.month}${date.day}';
@@ -304,16 +418,30 @@ 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}개 진행중';
@@ -325,92 +453,130 @@ class AppLocalizations {
return '$count in progress';
}
}
// 새로 추가된 동적 메서드들
String paymentDueInDays(int days) {
final template = _localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days';
final template =
_localizedStrings['paymentDueInDays'] ?? 'Payment due in @ days';
return template.replaceAll('@', days.toString());
}
String daysRemaining(int days) {
final template = _localizedStrings['daysRemaining'] ?? '@ days remaining';
return template.replaceAll('@', days.toString());
}
String exchangeRateFormat(String rate) {
final template = _localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
final template =
_localizedStrings['exchangeRateFormat'] ?? 'Today\'s rate: @';
return template.replaceAll('@', rate);
}
// 결제 주기 결제 메시지
String get billingCyclePayment => _localizedStrings['billingCyclePayment'] ?? '@ Payment';
String get billingCyclePayment =>
_localizedStrings['billingCyclePayment'] ?? '@ Payment';
// 할인 금액 표시 getter들
String get discountAmountWon => _localizedStrings['discountAmountWon'] ?? 'Save ₩@';
String get discountAmountDollar => _localizedStrings['discountAmountDollar'] ?? 'Save \$@';
String get discountAmountYen => _localizedStrings['discountAmountYen'] ?? 'Save ¥@';
String get discountAmountYuan => _localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
String get discountAmountWon =>
_localizedStrings['discountAmountWon'] ?? 'Save @';
String get discountAmountDollar =>
_localizedStrings['discountAmountDollar'] ?? 'Save \$@';
String get discountAmountYen =>
_localizedStrings['discountAmountYen'] ?? 'Save ¥@';
String get discountAmountYuan =>
_localizedStrings['discountAmountYuan'] ?? 'Save ¥@';
// 결제 주기 관련 getter
String get monthly => _localizedStrings['monthly'] ?? 'Monthly';
String get weekly => _localizedStrings['weekly'] ?? 'Weekly';
String get yearly => _localizedStrings['yearly'] ?? 'Yearly';
String get billingCycleMonthly => _localizedStrings['billingCycleMonthly'] ?? 'Monthly';
String get billingCycleQuarterly => _localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
String get billingCycleHalfYearly => _localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
String get billingCycleYearly => _localizedStrings['billingCycleYearly'] ?? 'Yearly';
String get billingCycleMonthly =>
_localizedStrings['billingCycleMonthly'] ?? 'Monthly';
String get billingCycleQuarterly =>
_localizedStrings['billingCycleQuarterly'] ?? 'Quarterly';
String get billingCycleHalfYearly =>
_localizedStrings['billingCycleHalfYearly'] ?? 'Half-Yearly';
String get billingCycleYearly =>
_localizedStrings['billingCycleYearly'] ?? 'Yearly';
// 색상 관련 getter
String get colorBlue => _localizedStrings['colorBlue'] ?? 'Blue';
String get colorGreen => _localizedStrings['colorGreen'] ?? 'Green';
String get colorOrange => _localizedStrings['colorOrange'] ?? 'Orange';
String get colorRed => _localizedStrings['colorRed'] ?? 'Red';
String get colorPurple => _localizedStrings['colorPurple'] ?? 'Purple';
// 날짜 형식 관련 getter
String get dateFormatFull => _localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
String get dateFormatFull =>
_localizedStrings['dateFormatFull'] ?? 'MMM dd, yyyy';
String get dateFormatShort => _localizedStrings['dateFormatShort'] ?? 'MM/dd';
// USD 환율 표시 형식
String get exchangeRateDisplay => _localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
String get exchangeRateDisplay =>
_localizedStrings['exchangeRateDisplay'] ?? '\$1 = @';
// 라벨 및 힌트 텍스트
String get labelServiceName => _localizedStrings['labelServiceName'] ?? 'Service Name';
String get hintServiceName => _localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
String get labelMonthlyExpense => _localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
String get labelNextBillingDate => _localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
String get labelWebsiteUrl => _localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
String get hintWebsiteUrl => _localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
String get labelEventPrice => _localizedStrings['labelEventPrice'] ?? 'Event Price';
String get hintEventPrice => _localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
String get labelServiceName =>
_localizedStrings['labelServiceName'] ?? 'Service Name';
String get hintServiceName =>
_localizedStrings['hintServiceName'] ?? 'e.g. Netflix, Spotify';
String get labelMonthlyExpense =>
_localizedStrings['labelMonthlyExpense'] ?? 'Monthly Expense';
String get labelNextBillingDate =>
_localizedStrings['labelNextBillingDate'] ?? 'Next Billing Date';
String get labelWebsiteUrl =>
_localizedStrings['labelWebsiteUrl'] ?? 'Website URL (Optional)';
String get hintWebsiteUrl =>
_localizedStrings['hintWebsiteUrl'] ?? 'https://example.com';
String get labelEventPrice =>
_localizedStrings['labelEventPrice'] ?? 'Event Price';
String get hintEventPrice =>
_localizedStrings['hintEventPrice'] ?? 'Enter discounted price';
String get labelCategory => _localizedStrings['labelCategory'] ?? 'Category';
// 기타 번역
String get subscription => _localizedStrings['subscription'] ?? 'Subscription';
String get subscription =>
_localizedStrings['subscription'] ?? 'Subscription';
String get movie => _localizedStrings['movie'] ?? 'Movie';
String get music => _localizedStrings['music'] ?? 'Music';
String get music => _localizedStrings['music'] ?? 'Music';
String get exercise => _localizedStrings['exercise'] ?? 'Exercise';
String get shopping => _localizedStrings['shopping'] ?? 'Shopping';
String get currency => _localizedStrings['currency'] ?? 'Currency';
String get websiteInfo => _localizedStrings['websiteInfo'] ?? 'Website Information';
String get cancelGuide => _localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
String get cancelServiceGuide => _localizedStrings['cancelServiceGuide'] ?? 'To cancel this service, please go to the cancellation page through the link below.';
String get goToCancelPage => _localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
String get urlAutoMatchInfo => _localizedStrings['urlAutoMatchInfo'] ?? 'If URL is empty, it will be automatically matched based on the service name';
String get websiteInfo =>
_localizedStrings['websiteInfo'] ?? 'Website Information';
String get cancelGuide =>
_localizedStrings['cancelGuide'] ?? 'Cancellation Guide';
String get cancelServiceGuide =>
_localizedStrings['cancelServiceGuide'] ??
'To cancel this service, please go to the cancellation page through the link below.';
String get goToCancelPage =>
_localizedStrings['goToCancelPage'] ?? 'Go to Cancellation Page';
String get urlAutoMatchInfo =>
_localizedStrings['urlAutoMatchInfo'] ??
'If URL is empty, it will be automatically matched based on the service name';
String get dateSelect => _localizedStrings['dateSelect'] ?? 'Select';
// 새로 추가된 getter들
String get serviceInfo => _localizedStrings['serviceInfo'] ?? 'Service Information';
String get newSubscriptionAdd => _localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
String get enterServiceInfo => _localizedStrings['enterServiceInfo'] ?? 'Enter service information';
String get addSubscriptionButton => _localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
String get serviceNameRequired => _localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
String get amountRequired => _localizedStrings['amountRequired'] ?? 'Please enter amount';
String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
String get serviceInfo =>
_localizedStrings['serviceInfo'] ?? 'Service Information';
String get newSubscriptionAdd =>
_localizedStrings['newSubscriptionAdd'] ?? 'Add New Subscription';
String get enterServiceInfo =>
_localizedStrings['enterServiceInfo'] ?? 'Enter service information';
String get addSubscriptionButton =>
_localizedStrings['addSubscriptionButton'] ?? 'Add Subscription';
String get serviceNameRequired =>
_localizedStrings['serviceNameRequired'] ?? 'Please enter service name';
String get amountRequired =>
_localizedStrings['amountRequired'] ?? 'Please enter amount';
String get subscriptionDetail =>
_localizedStrings['subscriptionDetail'] ?? 'Subscription Detail';
String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount';
String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
String get invalidAmount =>
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) {
switch (billingCycleKey) {
@@ -433,7 +599,7 @@ class AppLocalizations {
return billingCycleKey; // 매칭되지 않으면 원본 반환
}
}
// 카테고리 이름을 키로 변환하여 번역된 이름 반환
String getCategoryName(String categoryKey) {
switch (categoryKey) {
@@ -467,7 +633,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 {

View File

@@ -26,7 +26,7 @@ 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();

View File

@@ -75,7 +75,7 @@ class SubscriptionModel extends HiveObject {
if (!isEventActive || eventStartDate == null || eventEndDate == null) {
return false;
}
final now = DateTime.now();
return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!);
}
@@ -98,7 +98,7 @@ class SubscriptionModel extends HiveObject {
// 원래 가격 (이벤트와 관계없이 항상 정상 가격)
double get originalPrice => monthlyCost;
// 결제 주기를 영어 키값으로 정규화
static String normalizeBillingCycle(String cycle) {
switch (cycle.toLowerCase()) {
@@ -121,7 +121,7 @@ class SubscriptionModel extends HiveObject {
return 'monthly'; // 기본값은 monthly
}
}
// 결제 주기를 영어 키값으로 반환 (내부 사용)
String get billingCycleKey => normalizeBillingCycle(billingCycle);
}

View File

@@ -37,22 +37,24 @@ class AppNavigationObserver extends NavigatorObserver {
if (newRoute != null) {
_updateNavigationState(newRoute);
}
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
debugPrint(
'Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
}
void _updateNavigationState(Route<dynamic> route) {
if (navigator?.context == null) return;
final routeName = route.settings.name;
if (routeName == null) return;
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;
try {
final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateByRoute(routeName);
} catch (e) {
debugPrint('Failed to update navigation state: $e');
@@ -62,18 +64,19 @@ class AppNavigationObserver extends NavigatorObserver {
void _handlePopWithProvider() {
if (navigator?.context == null) return;
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;
try {
final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.pop();
} catch (e) {
debugPrint('Failed to handle pop with provider: $e');
}
});
}
}
}

View File

@@ -28,14 +28,14 @@ class CategoryProvider extends ChangeNotifier {
sortedCategories.sort((a, b) {
final aIndex = _categoryOrder.indexOf(a.name);
final bIndex = _categoryOrder.indexOf(b.name);
// 순서 목록에 없는 카테고리는 맨 뒤로
if (aIndex == -1) return 1;
if (bIndex == -1) return -1;
return aIndex.compareTo(bIndex);
});
return sortedCategories;
}
@@ -59,9 +59,17 @@ class CategoryProvider extends ChangeNotifier {
{'name': 'storageCloud', 'color': '#2196F3', 'icon': 'cloud'},
{'name': 'telecomInternetTv', 'color': '#00BCD4', 'icon': 'wifi'},
{'name': 'lifestyle', 'color': '#4CAF50', 'icon': 'home'},
{'name': 'shoppingEcommerce', 'color': '#FF9800', 'icon': 'shopping_cart'},
{
'name': 'shoppingEcommerce',
'color': '#FF9800',
'icon': 'shopping_cart'
},
{'name': 'programming', 'color': '#795548', 'icon': 'code'},
{'name': 'collaborationOffice', 'color': '#607D8B', 'icon': 'business_center'},
{
'name': 'collaborationOffice',
'color': '#607D8B',
'icon': 'business_center'
},
{'name': 'aiService', 'color': '#673AB7', 'icon': 'smart_toy'},
{'name': 'other', 'color': '#9E9E9E', 'icon': 'category'},
];
@@ -117,7 +125,7 @@ class CategoryProvider extends ChangeNotifier {
return null;
}
}
// 카테고리 이름을 현재 언어에 맞게 반환
String getLocalizedCategoryName(BuildContext context, String categoryKey) {
final localizations = AppLocalizations.of(context);

View File

@@ -5,24 +5,24 @@ import 'dart:ui' as ui;
class LocaleProvider extends ChangeNotifier {
late Box<String> _localeBox;
Locale _locale = const Locale('ko');
static const List<String> supportedLanguages = ['en', 'ko', 'ja', 'zh'];
Locale get locale => _locale;
Future<void> init() async {
_localeBox = await Hive.openBox<String>('locale');
// 저장된 언어 설정 확인
final savedLocale = _localeBox.get('locale');
if (savedLocale != null) {
// 저장된 언어가 있으면 사용
_locale = Locale(savedLocale);
} else {
// 저장된 언어가 없으면 시스템 언어 감지
final systemLocale = ui.PlatformDispatcher.instance.locale;
// 시스템 언어가 지원되는 언어인지 확인
if (supportedLanguages.contains(systemLocale.languageCode)) {
_locale = Locale(systemLocale.languageCode);
@@ -30,11 +30,11 @@ class LocaleProvider extends ChangeNotifier {
// 지원되지 않는 언어면 영어 사용
_locale = const Locale('en');
}
// 감지된 언어 저장
await _localeBox.put('locale', _locale.languageCode);
}
notifyListeners();
}

View File

@@ -36,25 +36,25 @@ class NavigationProvider extends ChangeNotifier {
void updateCurrentIndex(int index, {bool addToHistory = true}) {
if (_currentIndex == index) return;
_currentIndex = index;
_currentRoute = indexToRoute[index] ?? '/';
_currentTitle = indexToTitle[index] ?? 'home';
if (addToHistory && index >= 0) {
_navigationHistory.add(index);
if (_navigationHistory.length > 10) {
_navigationHistory.removeAt(0);
}
}
notifyListeners();
}
void updateByRoute(String route) {
final index = routeToIndex[route] ?? 0;
_currentRoute = route;
if (index >= 0) {
_currentIndex = index;
_currentTitle = indexToTitle[index] ?? 'home';
@@ -70,7 +70,7 @@ class NavigationProvider extends ChangeNotifier {
_currentTitle = 'home';
}
}
notifyListeners();
}
@@ -103,4 +103,4 @@ class NavigationProvider extends ChangeNotifier {
_navigationHistory.add(0);
notifyListeners();
}
}
}

View File

@@ -86,12 +86,12 @@ class NotificationProvider extends ChangeNotifier {
try {
_isEnabled = value;
await NotificationService.setNotificationEnabled(value);
// 첫 권한 부여 시 기본 설정 적용
if (value) {
await initializeDefaultSettingsOnFirstPermission();
}
notifyListeners();
} catch (e) {
debugPrint('알림 활성화 설정 중 오류 발생: $e');
@@ -270,15 +270,17 @@ class NotificationProvider extends ChangeNotifier {
// 첫 권한 부여 시 기본 설정 초기화
Future<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일 전 알림
await setDailyReminderEnabled(true); // 반복 알림 활성화
await setPaymentEnabled(true); // 결제 예정 알림 활성화
// 첫 권한 부여 플래그 저장
await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true');
await _secureStorage.write(
key: _firstPermissionGrantedKey, value: 'true');
}
} catch (e) {
debugPrint('기본 설정 초기화 중 오류 발생: $e');

View File

@@ -19,9 +19,9 @@ class SubscriptionProvider extends ChangeNotifier {
double get totalMonthlyExpense {
final exchangeRateService = ExchangeRateService();
final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
final total = _subscriptions.fold(
0.0,
(sum, subscription) {
@@ -31,11 +31,12 @@ class SubscriptionProvider extends ChangeNotifier {
'\$${price} ×$rate = ₩${price * rate}');
return sum + (price * rate);
}
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
debugPrint(
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price');
return sum + price;
},
);
debugPrint('[SubscriptionProvider] totalMonthlyExpense 계산 완료: '
'${_subscriptions.length}개 구독, 총액 ₩$total');
return total;
@@ -69,10 +70,10 @@ class SubscriptionProvider extends ChangeNotifier {
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
await refreshSubscriptions();
// categoryId 마이그레이션
await _migrateCategoryIds();
// 앱 시작 시 이벤트 상태 확인
await checkAndUpdateEventStatus();
@@ -90,11 +91,11 @@ class SubscriptionProvider extends ChangeNotifier {
try {
_subscriptions = _subscriptionBox.values.toList()
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
debugPrint('[SubscriptionProvider] refreshSubscriptions 완료: '
'${_subscriptions.length}개 구독, '
'총 월간 지출: ${totalMonthlyExpense.toStringAsFixed(2)}');
notifyListeners();
} catch (e) {
debugPrint('구독 목록 새로고침 중 오류 발생: $e');
@@ -139,7 +140,7 @@ class SubscriptionProvider extends ChangeNotifier {
await _subscriptionBox.put(subscription.id, subscription);
await refreshSubscriptions();
// 이벤트가 활성화된 경우 알림 스케줄 재설정
if (isEventActive && eventEndDate != null) {
await _scheduleEventEndNotification(subscription);
@@ -191,7 +192,6 @@ class SubscriptionProvider extends ChangeNotifier {
}
}
Future<void> clearAllSubscriptions() async {
_isLoading = true;
notifyListeners();
@@ -217,8 +217,9 @@ class SubscriptionProvider extends ChangeNotifier {
}
/// 이벤트 종료 알림을 스케줄링합니다.
Future<void> _scheduleEventEndNotification(SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
Future<void> _scheduleEventEndNotification(
SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode,
@@ -232,19 +233,18 @@ class SubscriptionProvider extends ChangeNotifier {
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
Future<void> checkAndUpdateEventStatus() async {
bool hasChanges = false;
for (var subscription in _subscriptions) {
// 이벤트가 종료되었지만 아직 활성화되어 있는 경우
if (subscription.isEventActive &&
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(DateTime.now())) {
subscription.isEventActive = false;
await _subscriptionBox.put(subscription.id, subscription);
hasChanges = true;
}
}
if (hasChanges) {
await refreshSubscriptions();
}
@@ -253,70 +253,73 @@ class SubscriptionProvider extends ChangeNotifier {
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산)
Future<double> calculateTotalExpense({String? locale}) async {
if (_subscriptions.isEmpty) return 0.0;
// locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = locale != null
? CurrencyUtil.getDefaultCurrency(locale)
: 'KRW'; // 기본값
final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency');
double total = 0.0;
for (final subscription in _subscriptions) {
final currentPrice = subscription.currentPrice;
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'${currentPrice} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice,
subscription.currency,
targetCurrency,
);
total += converted ?? currentPrice;
}
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total $targetCurrency');
return total;
}
/// 최근 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--) {
final month = DateTime(now.year, now.month - i, 1);
double monthTotal = 0.0;
// 현재 월인지 확인
final isCurrentMonth = (month.year == now.year && month.month == now.month);
final isCurrentMonth =
(month.year == now.year && month.month == now.month);
if (isCurrentMonth) {
debugPrint('[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
debugPrint(
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
}
// 해당 월에 활성화된 구독 계산
for (final subscription in _subscriptions) {
if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice;
debugPrint('[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'${cost} ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})');
// 통화 변환
final converted = await ExchangeRateService().convertBetweenCurrencies(
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
} else {
// 과거 월인 경우: 기존 로직 유지
@@ -324,46 +327,50 @@ class SubscriptionProvider extends ChangeNotifier {
final subscriptionStartDate = subscription.nextBillingDate.subtract(
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
);
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
if (subscriptionStartDate
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려)
double cost;
if (subscription.isEventActive &&
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
// 이벤트 기간과 해당 월이 겹치는지 확인
subscription.eventStartDate!.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventStartDate!
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventEndDate!.isAfter(month)) {
cost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
cost = subscription.monthlyCost;
}
// 통화 변환
final converted = await ExchangeRateService().convertBetweenCurrencies(
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
}
}
}
if (isCurrentMonth) {
debugPrint('[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
debugPrint(
'[getMonthlyExpenseData] 현재 월(${_getMonthLabel(month, locale ?? 'en')}) 총 지출: $monthTotal $targetCurrency');
}
monthlyData.add({
'month': month,
'totalExpense': monthTotal,
'monthName': _getMonthLabel(month, locale ?? 'en'),
});
}
return monthlyData;
}
@@ -409,96 +416,109 @@ class SubscriptionProvider extends ChangeNotifier {
/// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당
Future<void> _migrateCategoryIds() async {
debugPrint('❎ CategoryId 마이그레이션 시작...');
final categoryProvider = CategoryProvider();
await categoryProvider.init();
final categories = categoryProvider.categories;
int migratedCount = 0;
for (var subscription in _subscriptions) {
if (subscription.categoryId == null) {
final serviceName = subscription.serviceName.toLowerCase();
String? categoryId;
debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...');
// OTT 서비스
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓차') ||
serviceName.contains('티빙') ||
serviceName.contains('디즈니') ||
serviceName.contains('넷플릭스')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'OTT 서비스',
orElse: () => categories.first,
).id;
categoryId = categories
.firstWhere(
(cat) => cat.name == 'OTT 서비스',
orElse: () => categories.first,
)
.id;
}
// 음악 서비스
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벡스')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
).id;
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벡스')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'music',
orElse: () => categories.first,
)
.id;
}
// AI 서비스
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('midjourney') ||
serviceName.contains('copilot')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
).id;
else if (serviceName.contains('chatgpt') ||
serviceName.contains('claude') ||
serviceName.contains('midjourney') ||
serviceName.contains('copilot')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'aiService',
orElse: () => categories.first,
)
.id;
}
// 프로그래밍/개발
else if (serviceName.contains('github') ||
serviceName.contains('intellij') ||
serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'programming',
orElse: () => categories.first,
).id;
else if (serviceName.contains('github') ||
serviceName.contains('intellij') ||
serviceName.contains('webstorm') ||
serviceName.contains('jetbrains')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'programming',
orElse: () => categories.first,
)
.id;
}
// 오피스/협업 툴
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('slack') ||
serviceName.contains('figma') ||
serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) {
categoryId = categories.firstWhere(
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
).id;
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('slack') ||
serviceName.contains('figma') ||
serviceName.contains('icloud') ||
serviceName.contains('아이클라우드')) {
categoryId = categories
.firstWhere(
(cat) => cat.name == 'collaborationOffice',
orElse: () => categories.first,
)
.id;
}
// 기타 서비스 (기본값)
else {
categoryId = categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categories.first,
).id;
categoryId = categories
.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categories.first,
)
.id;
}
if (categoryId != null) {
subscription.categoryId = categoryId;
await subscription.save();
migratedCount++;
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name;
final categoryName =
categories.firstWhere((cat) => cat.id == categoryId).name;
debugPrint('${subscription.serviceName}$categoryName');
}
}
}
if (migratedCount > 0) {
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료');
await refreshSubscriptions();

View File

@@ -7,24 +7,24 @@ import '../theme/adaptive_theme.dart';
class ThemeProvider extends ChangeNotifier {
static const String _themeBoxName = 'theme_settings';
static const String _themeKey = 'theme_settings';
late Box<Map> _themeBox;
ThemeSettings _themeSettings = const ThemeSettings();
ThemeSettings get themeSettings => _themeSettings;
AppThemeMode get themeMode => _themeSettings.mode;
bool get useSystemColors => _themeSettings.useSystemColors;
bool get largeText => _themeSettings.largeText;
bool get reduceMotion => _themeSettings.reduceMotion;
bool get highContrast => _themeSettings.highContrast;
/// Provider 초기화
Future<void> initialize() async {
_themeBox = await Hive.openBox<Map>(_themeBoxName);
await _loadThemeSettings();
}
/// 저장된 테마 설정 로드
Future<void> _loadThemeSettings() async {
final savedSettings = _themeBox.get(_themeKey);
@@ -35,53 +35,53 @@ class ThemeProvider extends ChangeNotifier {
notifyListeners();
}
}
/// 테마 설정 저장
Future<void> _saveThemeSettings() async {
await _themeBox.put(_themeKey, _themeSettings.toJson());
}
/// 테마 모드 변경
Future<void> setThemeMode(AppThemeMode mode) async {
_themeSettings = _themeSettings.copyWith(mode: mode);
await _saveThemeSettings();
notifyListeners();
}
/// 시스템 색상 사용 설정
Future<void> setUseSystemColors(bool value) async {
_themeSettings = _themeSettings.copyWith(useSystemColors: value);
await _saveThemeSettings();
notifyListeners();
}
/// 큰 텍스트 설정
Future<void> setLargeText(bool value) async {
_themeSettings = _themeSettings.copyWith(largeText: value);
await _saveThemeSettings();
notifyListeners();
}
/// 모션 감소 설정
Future<void> setReduceMotion(bool value) async {
_themeSettings = _themeSettings.copyWith(reduceMotion: value);
await _saveThemeSettings();
notifyListeners();
}
/// 고대비 설정
Future<void> setHighContrast(bool value) async {
_themeSettings = _themeSettings.copyWith(highContrast: value);
await _saveThemeSettings();
notifyListeners();
}
/// 현재 설정에 따른 테마 가져오기
ThemeData getTheme(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness;
ThemeData baseTheme;
switch (_themeSettings.mode) {
case AppThemeMode.light:
baseTheme = AdaptiveTheme.lightTheme;
@@ -98,7 +98,7 @@ class ThemeProvider extends ChangeNotifier {
: AdaptiveTheme.lightTheme;
break;
}
// 접근성 설정 적용
return AdaptiveTheme.getAccessibleTheme(
baseTheme,
@@ -107,11 +107,11 @@ class ThemeProvider extends ChangeNotifier {
highContrast: _themeSettings.highContrast,
);
}
/// 현재 테마가 다크 모드인지 확인
bool isDarkMode(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness;
switch (_themeSettings.mode) {
case AppThemeMode.light:
return false;
@@ -122,7 +122,7 @@ class ThemeProvider extends ChangeNotifier {
return platformBrightness == Brightness.dark;
}
}
/// 테마 토글 (라이트/다크)
Future<void> toggleTheme() async {
if (_themeSettings.mode == AppThemeMode.light) {
@@ -137,7 +137,7 @@ class ThemeProvider extends ChangeNotifier {
class AnimatedThemeBuilder extends StatelessWidget {
final Widget Function(BuildContext, ThemeData) builder;
final Duration duration;
const AnimatedThemeBuilder({
super.key,
required this.builder,
@@ -148,7 +148,7 @@ class AnimatedThemeBuilder extends StatelessWidget {
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final theme = themeProvider.getTheme(context);
return AnimatedTheme(
data: theme,
duration: duration,
@@ -164,7 +164,7 @@ class ThemedColor extends StatelessWidget {
final Color lightColor;
final Color darkColor;
final Widget child;
const ThemedColor({
super.key,
required this.lightColor,
@@ -175,7 +175,7 @@ class ThemedColor extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = context.read<ThemeProvider>().isDarkMode(context);
return Theme(
data: Theme.of(context).copyWith(
primaryColor: isDark ? darkColor : lightColor,
@@ -183,4 +183,4 @@ class ThemedColor extends StatelessWidget {
child: child,
);
}
}
}

View File

@@ -6,6 +6,7 @@ import 'package:submanager/screens/sms_scan_screen.dart';
import 'package:submanager/screens/analysis_screen.dart';
import 'package:submanager/screens/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(),
};
}
@@ -32,29 +35,33 @@ class AppRoutes {
switch (routeSettings.name) {
case splash:
return _buildRoute(const SplashScreen(), routeSettings);
case main:
return _buildRoute(const MainScreen(), routeSettings);
case addSubscription:
return _buildRoute(const AddSubscriptionScreen(), routeSettings);
case subscriptionDetail:
final subscription = routeSettings.arguments as SubscriptionModel?;
if (subscription != null) {
return _buildRoute(DetailScreen(subscription: subscription), routeSettings);
return _buildRoute(
DetailScreen(subscription: subscription), routeSettings);
}
return _errorRoute();
case smsScanner:
return _buildRoute(const SmsScanScreen(), routeSettings);
case analysis:
return _buildRoute(const AnalysisScreen(), routeSettings);
case settings:
return _buildRoute(const SettingsScreen(), routeSettings);
case smsPermission:
return _buildRoute(const SmsPermissionScreen(), routeSettings);
default:
return _errorRoute();
}
@@ -77,15 +84,18 @@ class AppRoutes {
);
}
static void navigateTo(BuildContext context, String routeName, {Object? arguments}) {
static void navigateTo(BuildContext context, String routeName,
{Object? arguments}) {
Navigator.pushNamed(context, routeName, arguments: arguments);
}
static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) {
static void navigateAndReplace(BuildContext context, String routeName,
{Object? arguments}) {
Navigator.pushReplacementNamed(context, routeName, arguments: arguments);
}
static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) {
static void navigateAndRemoveUntil(BuildContext context, String routeName,
{Object? arguments}) {
Navigator.pushNamedAndRemoveUntil(
context,
routeName,
@@ -103,4 +113,4 @@ class AppRoutes {
static bool canPop(BuildContext context) {
return Navigator.canPop(context);
}
}
}

View File

@@ -62,14 +62,14 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: MediaQuery.of(context).padding.top + 60),
// 헤더 섹션
AddSubscriptionHeader(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
// 서비스 정보 폼
AddSubscriptionForm(
controller: _controller,
@@ -78,7 +78,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
setState: setState,
),
const SizedBox(height: 16),
// 이벤트/할인 섹션
AddSubscriptionEventSection(
controller: _controller,
@@ -87,7 +87,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
setState: setState,
),
const SizedBox(height: 32),
// 저장 버튼
AddSubscriptionSaveButton(
controller: _controller,
@@ -101,4 +101,4 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
),
);
}
}
}

View File

@@ -43,12 +43,14 @@ class _AnalysisScreenState extends State<AnalysisScreen>
// Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider);
debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
if (currentHash != _lastDataHash && !_isLoading && _lastDataHash.isNotEmpty) {
if (currentHash != _lastDataHash &&
!_isLoading &&
_lastDataHash.isNotEmpty) {
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
_loadData();
}
@@ -65,15 +67,16 @@ class _AnalysisScreenState extends State<AnalysisScreen>
String _calculateDataHash(SubscriptionProvider provider) {
final subscriptions = provider.subscriptions;
final buffer = StringBuffer();
buffer.write(subscriptions.length);
buffer.write('_');
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
for (final sub in subscriptions) {
buffer.write('_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
buffer.write(
'_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
}
return buffer.toString();
}
@@ -148,7 +151,7 @@ class _AnalysisScreenState extends State<AnalysisScreen>
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
// 네이티브 광고 위젯
SliverToBoxAdapter(
child: _buildAnimatedAd(),
@@ -197,4 +200,4 @@ class _AnalysisScreenState extends State<AnalysisScreen>
],
);
}
}
}

View File

@@ -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:
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:
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:
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:
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:
TextStyle(color: AppColors.darkNavy))),
],
onChanged: (value) {
setState(() {
@@ -117,14 +137,30 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
),
items: [
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(() {
@@ -163,7 +199,8 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
int.parse(category.color.replaceAll('#', '0xFF'))),
),
title: Text(
provider.getLocalizedCategoryName(context, category.name),
provider.getLocalizedCategoryName(
context, category.name),
style: TextStyle(
color: AppColors.darkNavy,
),

View File

@@ -43,7 +43,6 @@ class _DetailScreenState extends State<DetailScreen>
super.dispose();
}
@override
Widget build(BuildContext context) {
final baseColor = _controller.getCardColor();
@@ -53,111 +52,112 @@ class _DetailScreenState extends State<DetailScreen>
child: Scaffold(
backgroundColor: AppColors.backgroundColor,
body: CustomScrollView(
controller: _controller.scrollController,
slivers: [
// 상단 헤더 섹션
SliverToBoxAdapter(
child: DetailHeaderSection(
subscription: widget.subscription,
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
rotateAnimation: _controller.rotateAnimation!,
),
),
// 본문 콘텐츠
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// 편집 모드 안내
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
baseColor.withValues(alpha: 0.15),
baseColor.withValues(alpha: 0.08),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: baseColor.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.edit_rounded,
color: baseColor,
size: 20,
),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).editMode,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: baseColor,
),
),
const Spacer(),
Text(
AppLocalizations.of(context).changesAppliedAfterSave,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
),
),
],
),
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션
DetailFormSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 이벤트 가격 섹션
DetailEventSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 웹사이트 URL 섹션
DetailUrlSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 32),
// 액션 버튼
DetailActionButtons(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
],
controller: _controller.scrollController,
slivers: [
// 상단 헤더 섹션
SliverToBoxAdapter(
child: DetailHeaderSection(
subscription: widget.subscription,
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
rotateAnimation: _controller.rotateAnimation!,
),
),
),
],
// 본문 콘텐츠
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// 편집 모드 안내
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
baseColor.withValues(alpha: 0.15),
baseColor.withValues(alpha: 0.08),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: baseColor.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.edit_rounded,
color: baseColor,
size: 20,
),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).editMode,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: baseColor,
),
),
const Spacer(),
Text(
AppLocalizations.of(context)
.changesAppliedAfterSave,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
),
),
],
),
),
const SizedBox(height: 16),
// 기본 정보 폼 섹션
DetailFormSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 이벤트 가격 섹션
DetailEventSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 16),
// 웹사이트 URL 섹션
DetailUrlSection(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
const SizedBox(height: 32),
// 액션 버튼
DetailActionButtons(
controller: _controller,
fadeAnimation: _controller.fadeAnimation!,
slideAnimation: _controller.slideAnimation!,
),
],
),
),
),
],
),
),
);
}
}
}

View File

@@ -33,7 +33,7 @@ class _MainScreenState extends State<MainScreen>
late AnimationController _waveController;
late ScrollController _scrollController;
late FloatingNavBarScrollController _navBarScrollController;
// 화면 목록
late final List<Widget> _screens;
@@ -63,7 +63,7 @@ class _MainScreenState extends State<MainScreen>
);
_scrollController = ScrollController();
_navBarScrollController = FloatingNavBarScrollController(
scrollController: _scrollController,
onHide: () {},
@@ -157,7 +157,7 @@ class _MainScreenState extends State<MainScreen>
AppRoutes.addSubscription,
).then((result) {
_resetAnimations();
// 구독이 성공적으로 추가된 경우
if (result == true) {
// 상단에 스낵바 표시
@@ -203,18 +203,18 @@ class _MainScreenState extends State<MainScreen>
void _handleNavigation(int index, BuildContext context) {
final navigationProvider = context.read<NavigationProvider>();
// 이미 같은 인덱스면 무시
if (navigationProvider.currentIndex == index) {
return;
}
// 추가 버튼은 별도 처리
if (index == 2) {
_navigateToAddSubscription(context);
return;
}
// 인덱스 업데이트
navigationProvider.updateCurrentIndex(index);
}
@@ -222,7 +222,7 @@ class _MainScreenState extends State<MainScreen>
@override
Widget build(BuildContext context) {
final navigationProvider = context.watch<NavigationProvider>();
// 메인 그라데이션 사용
List<Color> backgroundGradient = AppColors.mainGradient;
@@ -235,8 +235,12 @@ class _MainScreenState extends State<MainScreen>
return GlassmorphicScaffold(
body: IndexedStack(
index: PlatformHelper.isIOS
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
: (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직
? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3
: (currentIndex == 3
? 3
: currentIndex == 4
? 4
: currentIndex), // Android: 기존 로직
children: _screens,
),
backgroundGradient: backgroundGradient,
@@ -249,4 +253,4 @@ class _MainScreenState extends State<MainScreen>
enableWaveAnimation: false,
);
}
}
}

View File

@@ -12,6 +12,8 @@ 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});
@@ -476,6 +478,60 @@ 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<bool>(
future: SMSService.hasSMSPermission(),
builder: (context, snapshot) {
final hasPermission = snapshot.data ?? false;
return ListTile(
leading: const Icon(
Icons.sms,
color: AppColors.textSecondary,
),
title: const Text(
'SMS 권한',
style: TextStyle(color: AppColors.textPrimary),
),
subtitle: Text(
AppLocalizations.of(context).smsPermissionRequired,
style:
const TextStyle(color: AppColors.textSecondary),
),
trailing: hasPermission
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.check_circle,
color: Colors.green),
)
: ElevatedButton(
onPressed: () async {
final granted =
await SMSService.requestSMSPermission();
if (!granted) {
final status =
await permission.Permission.sms.status;
if (status.isPermanentlyDenied) {
await permission.openAppSettings();
}
}
if (context.mounted) {
// 상태 갱신을 위해 다시 build 트리거
(context as Element).markNeedsBuild();
}
},
child: Text(AppLocalizations.of(context)
.requestPermission),
),
);
},
),
),
// 앱 정보
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import '../theme/app_colors.dart';
import '../widgets/glassmorphism_card.dart';
import '../routes/app_routes.dart';
import '../l10n/app_localizations.dart';
import '../services/sms_service.dart';
import '../utils/platform_helper.dart';
class SmsPermissionScreen extends StatefulWidget {
const SmsPermissionScreen({super.key});
@override
State<SmsPermissionScreen> createState() => _SmsPermissionScreenState();
}
class _SmsPermissionScreenState extends State<SmsPermissionScreen> {
bool _requesting = false;
Future<void> _handleRequest() async {
if (_requesting) return;
setState(() => _requesting = true);
try {
if (!PlatformHelper.isAndroid) {
// iOS/Web은 권한 흐름 없이 메인으로 이동
if (mounted) {
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
}
return;
}
final status = await permission.Permission.sms.status;
if (status.isGranted) {
if (mounted) {
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
}
return;
}
// 권한 요청
final granted = await SMSService.requestSMSPermission();
if (mounted) {
if (granted) {
Navigator.of(context).pushReplacementNamed(AppRoutes.main);
} else {
final newStatus = await permission.Permission.sms.status;
if (newStatus.isPermanentlyDenied) {
// 설정 열기 유도
_showSettingsDialog();
}
// 거부지만 영구 거부가 아니라면 그대로 대기 (사용자가 다시 시도 가능)
}
}
} finally {
if (mounted) setState(() => _requesting = false);
}
}
void _showSettingsDialog() {
final loc = AppLocalizations.of(context);
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(loc.smsPermissionRequired),
content: const Text('권한이 영구적으로 거부되었습니다. 설정에서 권한을 허용해주세요.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'),
),
TextButton(
onPressed: () async {
await permission.openAppSettings();
if (mounted) Navigator.of(context).pop();
},
child: const Text('설정 열기'),
),
],
),
);
}
@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(
'SMS 권한 요청',
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: const [
Text('이유:',
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('문자 메시지에서 반복 결제 내역을 분석해 구독 서비스를 자동으로 탐지합니다.'),
SizedBox(height: 12),
Text('수집 범위:',
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('결제 관련 문자 메시지(서비스명/금액/날짜 패턴)를 로컬에서만 처리합니다.'),
],
),
),
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.requestPermission),
),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => Navigator.of(context)
.pushReplacementNamed(AppRoutes.main),
child: const Text('나중에 하기'),
)
],
),
),
),
),
);
}
}

View File

@@ -75,7 +75,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
);
}
final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex];
final currentSubscription =
_controller.scannedSubscriptions[_controller.currentIndex];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -119,4 +120,4 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
),
);
}
}
}

View File

@@ -2,6 +2,8 @@ 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';
@@ -98,9 +100,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,

View File

@@ -66,7 +66,7 @@ class CurrencyUtil {
final locale = _getLocaleForCurrency(currency);
final symbol = getCurrencySymbol(currency);
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
return NumberFormat.currency(
locale: locale,
symbol: symbol,
@@ -81,27 +81,29 @@ class CurrencyUtil {
String locale,
) async {
final defaultCurrency = getDefaultCurrency(locale);
// 입력 통화가 기본 통화인 경우
if (currency == defaultCurrency) {
return _formatSingleCurrency(amount, currency);
}
// 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)';
}
}
// 영어 사용자가 KRW 선택한 경우
if (locale == 'en' && currency == 'KRW') {
return _formatSingleCurrency(amount, currency);
}
// 기타 통화 입력인 경우
return _formatSingleCurrency(amount, currency);
}
@@ -116,13 +118,13 @@ class CurrencyUtil {
for (var subscription in subscriptions) {
final price = subscription.currentPrice;
final converted = await _exchangeRateService.convertBetweenCurrencies(
price,
subscription.currency,
defaultCurrency,
);
total += converted ?? price;
}
@@ -178,13 +180,13 @@ class CurrencyUtil {
for (var subscription in subscriptions) {
if (subscription.isCurrentlyInEvent) {
final savings = subscription.eventSavings;
final converted = await _exchangeRateService.convertBetweenCurrencies(
savings,
subscription.currency,
defaultCurrency,
);
total += converted ?? savings;
}
}
@@ -204,7 +206,7 @@ class CurrencyUtil {
if (!subscription.isCurrentlyInEvent) {
return '';
}
final savings = subscription.eventSavings;
return formatAmountWithLocale(savings, subscription.currency, locale);
}
@@ -225,4 +227,4 @@ class CurrencyUtil {
static Future<String> formatAmount(double amount, String currency) async {
return formatAmountWithCurrencyAndLocale(amount, currency, 'ko');
}
}
}

View File

@@ -75,9 +75,10 @@ class ExchangeRateService {
}
/// USD 금액을 지정된 통화로 변환합니다.
Future<double?> convertUsdToTarget(double usdAmount, String targetCurrency) async {
Future<double?> convertUsdToTarget(
double usdAmount, String targetCurrency) async {
await _fetchAllRatesIfNeeded();
switch (targetCurrency) {
case 'KRW':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
@@ -96,9 +97,10 @@ class ExchangeRateService {
}
/// 지정된 통화를 USD로 변환합니다.
Future<double?> convertTargetToUsd(double amount, String sourceCurrency) async {
Future<double?> convertTargetToUsd(
double amount, String sourceCurrency) async {
await _fetchAllRatesIfNeeded();
switch (sourceCurrency) {
case 'KRW':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
@@ -118,25 +120,22 @@ 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;
}
// fromCurrency → USD → toCurrency
double? usdAmount;
if (fromCurrency == 'USD') {
usdAmount = amount;
} else {
usdAmount = await convertTargetToUsd(amount, fromCurrency);
}
if (usdAmount == null) return null;
if (toCurrency == 'USD') {
return usdAmount;
} else {
@@ -161,7 +160,7 @@ class ExchangeRateService {
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
await _fetchAllRatesIfNeeded();
switch (locale) {
case 'ko':
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
@@ -204,12 +203,13 @@ 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;
int decimalDigits;
switch (locale) {
case 'ko':
targetCurrency = 'KRW';
@@ -232,7 +232,7 @@ class ExchangeRateService {
default:
return '\$$usdAmount';
}
final convertedAmount = await convertUsdToTarget(usdAmount, targetCurrency);
if (convertedAmount != null) {
final formattedAmount = NumberFormat.currency(

View File

@@ -150,17 +150,16 @@ 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) {
final granted = await iosImplementation.requestPermissions(
alert: true,
@@ -170,20 +169,20 @@ class NotificationService {
return granted ?? false;
}
}
// Android 처리
if (Platform.isAndroid) {
final androidImplementation = _notifications
.resolvePlatformSpecificImplementation<
final androidImplementation =
_notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidImplementation != null) {
final granted = await androidImplementation
.requestNotificationsPermission();
final granted =
await androidImplementation.requestNotificationsPermission();
return granted ?? false;
}
}
return false;
}
@@ -191,32 +190,32 @@ class NotificationService {
static Future<bool> checkPermission() async {
// 웹 플랫폼인 경우 false 반환
if (_isWeb) return false;
// Android 처리
if (Platform.isAndroid) {
final androidImplementation = _notifications
.resolvePlatformSpecificImplementation<
final androidImplementation =
_notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidImplementation != null) {
// Android 13 이상에서만 권한 확인 필요
final isEnabled = await androidImplementation.areNotificationsEnabled();
return isEnabled ?? false;
}
}
// iOS 처리
if (Platform.isIOS) {
final iosImplementation = _notifications
.resolvePlatformSpecificImplementation<
final iosImplementation =
_notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosImplementation != null) {
final settings = await iosImplementation.checkPermissions();
return settings?.isEnabled ?? false;
}
}
return true; // 기본값
}
@@ -232,7 +231,7 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
const androidDetails = AndroidNotificationDetails(
'subscription_channel',
@@ -243,7 +242,7 @@ class NotificationService {
);
final iosDetails = const DarwinNotificationDetails();
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -305,7 +304,7 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
final notificationId = subscription.id.hashCode;
@@ -327,7 +326,7 @@ class NotificationService {
android: androidDetails,
iOS: iosDetails,
);
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -380,11 +379,11 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
final paymentDate = subscription.nextBillingDate;
final reminderDate = paymentDate.subtract(const Duration(days: 3));
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -433,11 +432,11 @@ class NotificationService {
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
return;
}
try {
final expirationDate = subscription.nextBillingDate;
final reminderDate = expirationDate.subtract(const Duration(days: 7));
// tz.local 초기화 확인 및 재시도
tz.Location location;
try {
@@ -510,16 +509,17 @@ class NotificationService {
location = tz.UTC;
}
}
// 기본 알림 예약 (지정된 일수 전)
final scheduledDate =
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
final scheduledDate = subscription.nextBillingDate
.subtract(Duration(days: reminderDays))
.copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
// 남은 일수에 따른 메시지 생성
String daysText = '$reminderDays일';
@@ -529,19 +529,21 @@ class NotificationService {
// 이벤트 종료로 인한 가격 변동 확인
String notificationBody;
if (subscription.isEventActive &&
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
// 이벤트가 결제일 전에 종료되는 경우
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}으로 변경됩니다.';
notificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}$daysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
// 일반 알림
final currentPrice = subscription.currentPrice;
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
notificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
}
await _notifications.zonedSchedule(
@@ -568,13 +570,14 @@ class NotificationService {
if (isDailyReminder && reminderDays >= 2) {
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
for (int i = reminderDays - 1; i >= 1; i--) {
final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
final dailyDate =
subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
hour: reminderHour,
minute: reminderMinute,
second: 0,
millisecond: 0,
microsecond: 0,
);
// 남은 일수에 따른 메시지 생성
String remainingDaysText = '$i일';
@@ -584,17 +587,21 @@ class NotificationService {
// 각 날짜에 대한 이벤트 종료 확인
String dailyNotificationBody;
if (subscription.isEventActive &&
if (subscription.isEventActive &&
subscription.eventEndDate != null &&
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!
.isBefore(subscription.nextBillingDate) &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
final eventPrice =
subscription.eventPrice ?? subscription.monthlyCost;
final normalPrice = subscription.monthlyCost;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}으로 변경됩니다.';
dailyNotificationBody =
'${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}$remainingDaysText 결제 예정입니다.\n'
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
} else {
final currentPrice = subscription.currentPrice;
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
dailyNotificationBody =
'${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
}
await _notifications.zonedSchedule(

View File

@@ -3,15 +3,17 @@ 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) {
try {
final subscription = _convertSingle(model);
result.add(subscription);
print('모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) {
print('모델 변환 중 오류 발생: $e');
}
@@ -76,4 +78,4 @@ class SubscriptionConverter {
return 'monthly'; // 기본값
}
}
}
}

View File

@@ -5,7 +5,8 @@ class SubscriptionFilter {
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) {
print('_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
print(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링
return scanned.where((scannedSub) {
@@ -16,7 +17,8 @@ class SubscriptionFilter {
final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost;
if (isSameName && isSameCost) {
print('중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
print(
'중복 발견: ${scannedSub.serviceName} (${scannedSub.monthlyCost}원)');
return true;
}
return false;
@@ -27,7 +29,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 +47,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();
}
@@ -52,9 +56,9 @@ class SubscriptionFilter {
List<Subscription> filterByCategories(
List<Subscription> subscriptions, List<String> categoryIds) {
if (categoryIds.isEmpty) return subscriptions;
return subscriptions.where((sub) {
return sub.category != null && categoryIds.contains(sub.category);
}).toList();
}
}
}

View File

@@ -82,7 +82,7 @@ class SmsScanner {
try {
final messages = await _query.getAllSms;
final smsList = <Map<String, dynamic>>[];
// SMS 메시지를 분석하여 구독 서비스 감지
for (final message in messages) {
final parsedData = _parseRawSms(message);
@@ -90,7 +90,7 @@ class SmsScanner {
smsList.add(parsedData);
}
}
return smsList;
} catch (e) {
print('SmsScanner: Android SMS 스캔 실패: $e');
@@ -104,41 +104,59 @@ class SmsScanner {
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'
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'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())
);
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(),
'nextBillingDate':
_calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
};
} catch (e) {
@@ -146,7 +164,7 @@ class SmsScanner {
return null;
}
}
// 서비스명 추출 로직
String _extractServiceName(String body, String sender) {
// 알려진 서비스 매핑
@@ -162,41 +180,41 @@ class SmsScanner {
'멜론': '멜론',
'웨이브': '웨이브',
};
// 메시지나 발신자에서 서비스명 찾기
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})*)'), // 결제 금액
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) {
@@ -205,26 +223,29 @@ class SmsScanner {
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')) {
} 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) {
DateTime _calculateNextBillingFromDate(
DateTime lastDate, String billingCycle) {
switch (billingCycle) {
case 'monthly':
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
@@ -241,7 +262,8 @@ class SmsScanner {
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;
@@ -369,8 +391,6 @@ class SmsScanner {
return serviceUrls[serviceName];
}
// 메시지에서 통화 단위를 감지하는 함수
String _detectCurrency(String message) {
final dollarKeywords = [
@@ -407,4 +427,4 @@ class SmsScanner {
// 기본값은 원화
return 'KRW';
}
}
}

View File

@@ -9,26 +9,26 @@ class SMSService {
static Future<bool> requestSMSPermission() async {
// 웹이나 iOS에서는 SMS 권한 불필요
if (kIsWeb || PlatformHelper.isIOS) return true;
// Android에서만 권한 요청
if (PlatformHelper.isAndroid) {
final status = await permission.Permission.sms.request();
return status.isGranted;
}
return false;
}
static Future<bool> hasSMSPermission() async {
// 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
if (kIsWeb || PlatformHelper.isIOS) return true;
// Android에서만 실제 권한 확인
if (PlatformHelper.isAndroid) {
final status = await permission.Permission.sms.status;
return status.isGranted;
}
return false;
}

View File

@@ -17,39 +17,40 @@ class SubscriptionUrlMatcher {
static CancellationUrlService? _cancellationService;
static ServiceNameResolver? _nameResolver;
static SmsExtractorService? _smsExtractor;
/// 서비스 초기화
static Future<void> initialize() async {
if (_dataRepository != null && _dataRepository!.isInitialized) return;
// 1. 데이터 저장소 초기화
_dataRepository = ServiceDataRepository();
await _dataRepository!.initialize();
// 2. 서비스 초기화
_categoryMapper = CategoryMapperService(_dataRepository!);
_urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!);
_cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!);
_cancellationService =
CancellationUrlService(_dataRepository!, _urlMatcher!);
_nameResolver = ServiceNameResolver(_dataRepository!);
_smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!);
}
/// 도메인 추출 (www와 TLD 제외)
static String? extractDomain(String url) {
return _urlMatcher?.extractDomain(url);
}
/// URL로 서비스 찾기
static Future<ServiceInfo?> findServiceByUrl(String url) async {
await initialize();
return _urlMatcher?.findServiceByUrl(url);
}
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
static String? suggestUrl(String serviceName) {
return _urlMatcher?.suggestUrl(serviceName);
}
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
static Future<String?> findCancellationUrl({
String? serviceName,
@@ -63,19 +64,20 @@ class SubscriptionUrlMatcher {
locale: locale,
);
}
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
await initialize();
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false;
return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ??
false;
}
/// 서비스명으로 카테고리 찾기
static Future<String?> findCategoryByServiceName(String serviceName) async {
await initialize();
return _categoryMapper?.findCategoryByServiceName(serviceName);
}
/// 현재 로케일에 따라 서비스 표시명 가져오기
static Future<String> getServiceDisplayName({
required String serviceName,
@@ -83,17 +85,18 @@ class SubscriptionUrlMatcher {
}) async {
await initialize();
return await _nameResolver?.getServiceDisplayName(
serviceName: serviceName,
locale: locale,
) ?? serviceName;
serviceName: serviceName,
locale: locale,
) ??
serviceName;
}
/// SMS에서 URL과 서비스 정보 추출
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
await initialize();
return _smsExtractor?.extractServiceFromSms(smsText);
}
/// URL이 알려진 서비스 URL인지 확인
static Future<bool> isKnownServiceUrl(String url) async {
await initialize();
@@ -104,4 +107,4 @@ class SubscriptionUrlMatcher {
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch);
}
}
}

View File

@@ -336,22 +336,22 @@ class LegacyServiceData {
// 모든 서비스 매핑을 합친 맵
static Map<String, String> get allServices => {
...ottServices,
...musicServices,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
...ottServices,
...musicServices,
...storageServices,
...aiServices,
...programmingServices,
...officeTools,
...lifestyleServices,
...shoppingServices,
...telecomServices,
...otherServices,
};
/// 서비스 카테고리 찾기
static String? getCategoryForService(String serviceName) {
final lowerName = serviceName.toLowerCase();
if (ottServices.containsKey(lowerName)) return 'ott';
if (musicServices.containsKey(lowerName)) return 'music';
if (storageServices.containsKey(lowerName)) return 'storage';
@@ -362,7 +362,7 @@ class LegacyServiceData {
if (shoppingServices.containsKey(lowerName)) return 'shopping';
if (telecomServices.containsKey(lowerName)) return 'telecom';
if (otherServices.containsKey(lowerName)) return 'other';
return null;
}
}
}

View File

@@ -5,13 +5,14 @@ import 'package:flutter/services.dart';
class ServiceDataRepository {
Map<String, dynamic>? _servicesData;
bool _isInitialized = false;
/// JSON 데이터 초기화
Future<void> initialize() async {
if (_isInitialized) return;
try {
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
final jsonString =
await rootBundle.loadString('assets/data/subscription_services.json');
_servicesData = json.decode(jsonString);
_isInitialized = true;
print('ServiceDataRepository: JSON 데이터 로드 완료');
@@ -21,10 +22,10 @@ class ServiceDataRepository {
_isInitialized = true;
}
}
/// 서비스 데이터 가져오기
Map<String, dynamic>? getServicesData() => _servicesData;
/// 초기화 여부 확인
bool get isInitialized => _isInitialized;
}
}

View File

@@ -7,7 +7,7 @@ class ServiceInfo {
final String categoryId;
final String categoryNameKr;
final String categoryNameEn;
ServiceInfo({
required this.serviceId,
required this.serviceName,
@@ -17,4 +17,4 @@ class ServiceInfo {
required this.categoryNameKr,
required this.categoryNameEn,
});
}
}

View File

@@ -6,9 +6,9 @@ import 'url_matcher_service.dart';
class CancellationUrlService {
final ServiceDataRepository _dataRepository;
final UrlMatcherService _urlMatcher;
CancellationUrlService(this._dataRepository, this._urlMatcher);
/// 서비스명 또는 URL로 해지 안내 페이지 URL 찾기
Future<String?> findCancellationUrl({
String? serviceName,
@@ -19,47 +19,55 @@ class CancellationUrlService {
final servicesData = _dataRepository.getServicesData();
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>;
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] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
}
}
}
// 2. URL로 찾기
if (websiteUrl != null && websiteUrl.isNotEmpty) {
final domain = _urlMatcher.extractDomain(websiteUrl);
if (domain != null) {
for (final categoryData in categories.values) {
final services = (categoryData as Map<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'];
return cancellationUrls[locale] ??
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
}
}
}
@@ -68,7 +76,7 @@ class CancellationUrlService {
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
}
@@ -126,4 +134,4 @@ class CancellationUrlService {
);
return cancellationUrl != null;
}
}
}

View File

@@ -4,41 +4,43 @@ import '../data/legacy_service_data.dart';
/// 카테고리 매핑 관련 기능을 제공하는 서비스 클래스
class CategoryMapperService {
final ServiceDataRepository _dataRepository;
CategoryMapperService(this._dataRepository);
/// 서비스명으로 카테고리 찾기
Future<String?> findCategoryByServiceName(String serviceName) async {
if (serviceName.isEmpty) return null;
final lowerName = serviceName.toLowerCase().trim();
// JSON 데이터가 있으면 JSON에서 찾기
final servicesData = _dataRepository.getServicesData();
if (servicesData != null) {
final categories = servicesData['categories'] as Map<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'] ?? []);
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);
}
}
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
return getCategoryForLegacyService(serviceName);
}
/// 카테고리 키를 실제 카테고리 ID로 매핑
String getCategoryIdByKey(String key) {
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
@@ -68,21 +70,30 @@ class CategoryMapperService {
return 'other';
}
}
/// 레거시 서비스명으로 카테고리 추측
String getCategoryForLegacyService(String serviceName) {
final lowerName = serviceName.toLowerCase();
if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services';
if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming';
if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage';
if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services';
if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools';
if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools';
if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle';
if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping';
if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom';
if (LegacyServiceData.ottServices.containsKey(lowerName))
return 'ott_services';
if (LegacyServiceData.musicServices.containsKey(lowerName))
return 'music_streaming';
if (LegacyServiceData.storageServices.containsKey(lowerName))
return 'cloud_storage';
if (LegacyServiceData.aiServices.containsKey(lowerName))
return 'ai_services';
if (LegacyServiceData.programmingServices.containsKey(lowerName))
return 'dev_tools';
if (LegacyServiceData.officeTools.containsKey(lowerName))
return 'office_tools';
if (LegacyServiceData.lifestyleServices.containsKey(lowerName))
return 'lifestyle';
if (LegacyServiceData.shoppingServices.containsKey(lowerName))
return 'shopping';
if (LegacyServiceData.telecomServices.containsKey(lowerName))
return 'telecom';
return 'other';
}
}
}

View File

@@ -3,9 +3,9 @@ import '../data/service_data_repository.dart';
/// 서비스명 관련 기능을 제공하는 서비스 클래스
class ServiceNameResolver {
final ServiceDataRepository _dataRepository;
ServiceNameResolver(this._dataRepository);
/// 현재 로케일에 따라 서비스 표시명 가져오기
Future<String> getServiceDisplayName({
required String serviceName,
@@ -15,22 +15,23 @@ class ServiceNameResolver {
if (servicesData == null) {
return serviceName;
}
final lowerName = serviceName.toLowerCase().trim();
final categories = servicesData['categories'] as Map<String, dynamic>;
// 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>;
final names = List<String>.from(data['names'] ?? []);
// names 배열에 있는지 확인
for (final name in names) {
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
if (lowerName == name.toLowerCase() ||
lowerName.contains(name.toLowerCase()) ||
name.toLowerCase().contains(lowerName)) {
// 로케일에 따라 적절한 이름 반환
if (locale == 'ko' || locale == 'kr') {
@@ -40,11 +41,11 @@ class ServiceNameResolver {
}
}
}
// nameKr/nameEn에 직접 매칭 확인
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
if (lowerName == nameKr || lowerName == nameEn) {
if (locale == 'ko' || locale == 'kr') {
return data['nameKr'] ?? serviceName;
@@ -54,8 +55,8 @@ class ServiceNameResolver {
}
}
}
// 찾지 못한 경우 원래 이름 반환
return serviceName;
}
}
}

View File

@@ -7,9 +7,9 @@ import 'category_mapper_service.dart';
class SmsExtractorService {
final UrlMatcherService _urlMatcher;
final CategoryMapperService _categoryMapper;
SmsExtractorService(this._urlMatcher, this._categoryMapper);
/// SMS에서 URL과 서비스 정보 추출
Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
// URL 패턴 찾기
@@ -17,9 +17,9 @@ class SmsExtractorService {
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
caseSensitive: false,
);
final matches = urlPattern.allMatches(smsText);
for (final match in matches) {
final url = match.group(0);
if (url != null) {
@@ -29,15 +29,17 @@ class SmsExtractorService {
}
}
}
// URL로 못 찾았으면 서비스명으로 시도
final lowerSms = smsText.toLowerCase();
// 모든 서비스명 검사
for (final entry in LegacyServiceData.allServices.entries) {
if (lowerSms.contains(entry.key.toLowerCase())) {
final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other';
final categoryId =
await _categoryMapper.findCategoryByServiceName(entry.key) ??
'other';
return ServiceInfo(
serviceId: entry.key,
serviceName: entry.key,
@@ -49,7 +51,7 @@ class SmsExtractorService {
);
}
}
return null;
}
}
}

View File

@@ -7,23 +7,23 @@ import 'category_mapper_service.dart';
class UrlMatcherService {
final ServiceDataRepository _dataRepository;
final CategoryMapperService _categoryMapper;
UrlMatcherService(this._dataRepository, this._categoryMapper);
/// 도메인 추출 (www와 TLD 제외)
String? extractDomain(String url) {
try {
final uri = Uri.parse(url);
final host = uri.host.toLowerCase();
// 도메인 부분 추출
var parts = host.split('.');
// www 제거
if (parts.isNotEmpty && parts[0] == 'www') {
parts = parts.sublist(1);
}
// 서브도메인 처리 (예: music.youtube.com)
if (parts.length >= 3) {
// 서브도메인 포함 전체 도메인 반환
@@ -32,40 +32,41 @@ class UrlMatcherService {
// 메인 도메인만 반환
return parts[0];
}
return null;
} catch (e) {
print('UrlMatcherService: 도메인 추출 실패 - $e');
return null;
}
}
/// URL로 서비스 찾기
Future<ServiceInfo?> findServiceByUrl(String url) async {
final domain = extractDomain(url);
if (domain == null) return null;
// JSON 데이터가 있으면 JSON에서 찾기
final servicesData = _dataRepository.getServicesData();
if (servicesData != null) {
final categories = servicesData['categories'] as Map<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)) {
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,
@@ -80,13 +81,13 @@ class UrlMatcherService {
}
}
}
// JSON에서 못 찾았으면 레거시 방식으로 찾기
for (final entry in LegacyServiceData.allServices.entries) {
final serviceUrl = entry.value;
final serviceDomain = extractDomain(serviceUrl);
if (serviceDomain != null &&
if (serviceDomain != null &&
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
return ServiceInfo(
serviceId: entry.key,
@@ -99,10 +100,10 @@ class UrlMatcherService {
);
}
}
return null;
}
/// 서비스명으로 URL 찾기
String? suggestUrl(String serviceName) {
if (serviceName.isEmpty) {
@@ -186,7 +187,7 @@ class UrlMatcherService {
return null;
}
}
/// URL이 알려진 서비스 URL인지 확인
Future<bool> isKnownServiceUrl(String url) async {
final serviceInfo = await findServiceByUrl(url);
@@ -232,4 +233,4 @@ class UrlMatcherService {
return null;
}
}
}

View File

@@ -1,2 +1,2 @@
/// URL Matcher 패키지의 export 파일
export 'models/service_info.dart';
export 'models/service_info.dart';

View File

@@ -7,7 +7,7 @@ import 'app_theme.dart';
class AdaptiveTheme {
/// 라이트 테마
static ThemeData get lightTheme => AppTheme.lightTheme;
/// 다크 테마
static ThemeData get darkTheme {
return ThemeData(
@@ -22,21 +22,19 @@ class AdaptiveTheme {
background: const Color(0xFF121212),
surface: const 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 +51,6 @@ class AdaptiveTheme {
size: 24,
),
),
textTheme: TextTheme(
headlineLarge: const TextStyle(
color: Colors.white,
@@ -119,22 +116,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 +150,6 @@ class AdaptiveTheme {
fontWeight: FontWeight.w400,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
@@ -164,7 +162,6 @@ class AdaptiveTheme {
elevation: 0,
),
),
dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1),
thickness: 1,
@@ -172,7 +169,7 @@ class AdaptiveTheme {
),
);
}
/// OLED 최적화 다크 테마
static ThemeData get oledTheme {
return darkTheme.copyWith(
@@ -192,7 +189,7 @@ class AdaptiveTheme {
),
);
}
/// 고대비 테마
static ThemeData get highContrastTheme {
return ThemeData(
@@ -206,7 +203,6 @@ class AdaptiveTheme {
background: Colors.white,
surface: Colors.white,
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
color: Colors.black,
@@ -234,7 +230,6 @@ class AdaptiveTheme {
fontWeight: FontWeight.w500,
),
),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
@@ -242,7 +237,6 @@ class AdaptiveTheme {
side: const BorderSide(color: Colors.black, width: 2),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
@@ -255,31 +249,28 @@ class AdaptiveTheme {
),
);
}
/// 시스템 테마에 따른 상태바 스타일 적용
static void applySystemUIOverlay(BuildContext context) {
final brightness = Theme.of(context).brightness;
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
statusBarBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
systemNavigationBarColor: isOled
? Colors.black
: (brightness == Brightness.dark
? const Color(0xFF121212)
statusBarIconBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
statusBarBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isOled
? Colors.black
: (brightness == Brightness.dark
? const Color(0xFF121212)
: Colors.white),
systemNavigationBarIconBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
systemNavigationBarIconBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
));
}
/// 접근성 설정에 따른 테마 조정
static ThemeData getAccessibleTheme(
ThemeData baseTheme, {
@@ -290,9 +281,9 @@ class AdaptiveTheme {
if (highContrast) {
return highContrastTheme;
}
ThemeData theme = baseTheme;
if (largeText) {
theme = theme.copyWith(
textTheme: theme.textTheme.apply(
@@ -300,7 +291,7 @@ class AdaptiveTheme {
),
);
}
if (reduceMotion) {
theme = theme.copyWith(
pageTransitionsTheme: const PageTransitionsTheme(
@@ -311,7 +302,7 @@ class AdaptiveTheme {
),
);
}
return theme;
}
}
@@ -331,7 +322,7 @@ class ThemeSettings {
final bool largeText;
final bool reduceMotion;
final bool highContrast;
const ThemeSettings({
this.mode = AppThemeMode.system,
this.useSystemColors = false,
@@ -339,7 +330,7 @@ class ThemeSettings {
this.reduceMotion = false,
this.highContrast = false,
});
ThemeSettings copyWith({
AppThemeMode? mode,
bool? useSystemColors,
@@ -355,15 +346,15 @@ class ThemeSettings {
highContrast: highContrast ?? this.highContrast,
);
}
Map<String, dynamic> toJson() => {
'mode': mode.name,
'useSystemColors': useSystemColors,
'largeText': largeText,
'reduceMotion': reduceMotion,
'highContrast': highContrast,
};
'mode': mode.name,
'useSystemColors': useSystemColors,
'largeText': largeText,
'reduceMotion': reduceMotion,
'highContrast': highContrast,
};
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
mode: AppThemeMode.values.firstWhere(
@@ -376,4 +367,4 @@ class ThemeSettings {
highContrast: json['highContrast'] ?? false,
);
}
}
}

View File

@@ -27,14 +27,14 @@ class AppColors {
// 보더 & 디바이더
static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200
static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200
// 그림자 (color.md 가이드)
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
// 그라데이션 컬러 - 다양한 효과를 위한 조합
static const List<Color> blueGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA) // 스카이 블루
Color(0xFF60A5FA) // 스카이 블루
];
static const List<Color> tealGradient = [
Color(0xFF14B8A6),
@@ -59,52 +59,52 @@ class AppColors {
static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity)
static const glassBorder = Color(0xFF2563EB); // 딥 블루 테두리
static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity)
// 다크 모드용 Glassmorphism 색상
static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity)
static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity)
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
// 백드롭 블러 효과를 위한 그라디언트
static const List<Color> glassGradient = [
Color(0x33FFFFFF), // 20% white
Color(0x1AFFFFFF), // 10% white
];
static const List<Color> glassGradientDark = [
Color(0x1A000000), // 10% black
Color(0x0F000000), // 6% black
];
// 메인 그라데이션
static const List<Color> mainGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA), // 스카이 블루
Color(0xFFE0E7EF), // 라이트 그레이
];
static const List<Color> accentGradient = [
Color(0xFF38BDF8), // 소프트 민트
Color(0xFF60A5FA), // 스카이 블루
];
// 시간대별 배경 그라디언트
static const List<Color> morningGradient = [
Color(0xFFFED7AA), // 따뜻한 오렌지
Color(0xFFFBBF24), // 부드러운 노랑
];
static const List<Color> dayGradient = [
Color(0xFFDDEAFC), // 연한 하늘색
Color(0xFFBFDBFE), // 맑은 파랑
];
static const List<Color> eveningGradient = [
Color(0xFFFCA5A5), // 부드러운 핑크
Color(0xFFC084FC), // 연한 보라
];
static const List<Color> nightGradient = [
Color(0xFF4338CA), // 깊은 인디고
Color(0xFF1E1B4B), // 다크 네이비

View File

@@ -52,21 +52,21 @@ class AppTheme {
textTheme: const TextTheme(
// 헤드라인 - 페이지 제목
headlineLarge: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
@@ -75,21 +75,21 @@ class AppTheme {
// 타이틀 - 카드, 섹션 제목
titleLarge: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
@@ -98,21 +98,21 @@ class AppTheme {
// 본문 텍스트
bodyLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
@@ -121,21 +121,21 @@ class AppTheme {
// 라벨 텍스트
labelLarge: TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.4,
),
labelMedium: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
height: 1.4,
),
labelSmall: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,

View File

@@ -4,42 +4,42 @@ import 'dart:io' show Platform;
/// 햅틱 피드백을 관리하는 헬퍼 클래스
class HapticFeedbackHelper {
static bool _isEnabled = true;
/// 햅틱 피드백 활성화 여부 설정
static void setEnabled(bool enabled) {
_isEnabled = enabled;
}
/// 가벼운 햅틱 피드백
static Future<void> lightImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.lightImpact();
}
/// 중간 강도 햅틱 피드백
static Future<void> mediumImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.mediumImpact();
}
/// 강한 햅틱 피드백
static Future<void> heavyImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.heavyImpact();
}
/// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine)
static Future<void> selectionClick() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.selectionClick();
}
/// 진동 패턴 (Android)
static Future<void> vibrate({int duration = 50}) async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.vibrate();
}
/// 성공 피드백 패턴
static Future<void> success() async {
if (!_isEnabled || !_isPlatformSupported()) return;
@@ -47,7 +47,7 @@ class HapticFeedbackHelper {
await Future.delayed(const Duration(milliseconds: 100));
await HapticFeedback.lightImpact();
}
/// 에러 피드백 패턴
static Future<void> error() async {
if (!_isEnabled || !_isPlatformSupported()) return;
@@ -55,13 +55,13 @@ class HapticFeedbackHelper {
await Future.delayed(const Duration(milliseconds: 100));
await HapticFeedback.heavyImpact();
}
/// 경고 피드백 패턴
static Future<void> warning() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.mediumImpact();
}
/// 플랫폼이 햅틱 피드백을 지원하는지 확인
static bool _isPlatformSupported() {
try {
@@ -71,4 +71,4 @@ class HapticFeedbackHelper {
return false;
}
}
}
}

View File

@@ -7,19 +7,19 @@ class MemoryManager {
static final MemoryManager _instance = MemoryManager._internal();
factory MemoryManager() => _instance;
MemoryManager._internal();
// 캐시 관리
final Map<String, _CacheEntry> _cache = {};
final int _maxCacheSize = 100;
final Duration _defaultTTL = const Duration(minutes: 5);
// 이미지 캐시 관리
static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB
static const int maxImageCacheCount = 100;
// 위젯 참조 추적
final Map<String, WeakReference<State>> _widgetReferences = {};
/// 캐시에 데이터 저장
void cacheData<T>({
required String key,
@@ -27,32 +27,32 @@ class MemoryManager {
Duration? ttl,
}) {
_cleanupExpiredCache();
if (_cache.length >= _maxCacheSize) {
_evictOldestEntry();
}
_cache[key] = _CacheEntry(
data: data,
timestamp: DateTime.now(),
ttl: ttl ?? _defaultTTL,
);
}
/// 캐시에서 데이터 가져오기
T? getCachedData<T>(String key) {
final entry = _cache[key];
if (entry == null) return null;
if (entry.isExpired) {
_cache.remove(key);
return null;
}
entry.lastAccess = DateTime.now();
return entry.data as T?;
}
/// 캐시 비우기
void clearCache() {
_cache.clear();
@@ -60,53 +60,52 @@ class MemoryManager {
print('🧹 메모리 캐시가 비워졌습니다.');
}
}
/// 특정 패턴의 캐시 제거
void clearCacheByPattern(String pattern) {
final keysToRemove = _cache.keys
.where((key) => key.contains(pattern))
.toList();
final keysToRemove =
_cache.keys.where((key) => key.contains(pattern)).toList();
for (final key in keysToRemove) {
_cache.remove(key);
}
}
/// 만료된 캐시 정리
void _cleanupExpiredCache() {
final expiredKeys = _cache.entries
.where((entry) => entry.value.isExpired)
.map((entry) => entry.key)
.toList();
for (final key in expiredKeys) {
_cache.remove(key);
}
}
/// 가장 오래된 캐시 항목 제거
void _evictOldestEntry() {
if (_cache.isEmpty) return;
var oldestKey = _cache.keys.first;
var oldestTime = _cache[oldestKey]!.lastAccess;
for (final entry in _cache.entries) {
if (entry.value.lastAccess.isBefore(oldestTime)) {
oldestKey = entry.key;
oldestTime = entry.value.lastAccess;
}
}
_cache.remove(oldestKey);
}
/// 이미지 캐시 최적화
static void optimizeImageCache() {
PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount;
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
}
/// 이미지 캐시 상태 확인
static ImageCacheStatus getImageCacheStatus() {
final cache = PaintingBinding.instance.imageCache;
@@ -117,7 +116,7 @@ class MemoryManager {
maximumSizeBytes: cache.maximumSizeBytes,
);
}
/// 이미지 캐시 비우기
static void clearImageCache() {
PaintingBinding.instance.imageCache.clear();
@@ -126,24 +125,22 @@ class MemoryManager {
print('🖼️ 이미지 캐시가 비워졌습니다.');
}
}
/// 위젯 참조 추적
void trackWidget(String key, State widget) {
_widgetReferences[key] = WeakReference(widget);
}
/// 위젯 참조 제거
void untrackWidget(String key) {
_widgetReferences.remove(key);
}
/// 살아있는 위젯 수 확인
int getAliveWidgetCount() {
return _widgetReferences.values
.where((ref) => ref.target != null)
.length;
return _widgetReferences.values.where((ref) => ref.target != null).length;
}
/// 메모리 압박 시 대응
void handleMemoryPressure() {
// 캐시 50% 제거
@@ -151,43 +148,43 @@ class MemoryManager {
for (final key in keysToRemove) {
_cache.remove(key);
}
// 이미지 캐시 축소
final imageCache = PaintingBinding.instance.imageCache;
imageCache.maximumSize = maxImageCacheCount ~/ 2;
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
if (kDebugMode) {
print('⚠️ 메모리 압박 대응: 캐시 크기 감소');
}
}
/// 자동 메모리 정리 시작
Timer? _cleanupTimer;
void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) {
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(interval, (_) {
_cleanupExpiredCache();
// 죽은 위젯 참조 제거
final deadKeys = _widgetReferences.entries
.where((entry) => entry.value.target == null)
.map((entry) => entry.key)
.toList();
for (final key in deadKeys) {
_widgetReferences.remove(key);
}
});
}
/// 자동 메모리 정리 중지
void stopAutoCleanup() {
_cleanupTimer?.cancel();
_cleanupTimer = null;
}
/// 메모리 사용량 리포트
Map<String, dynamic> getMemoryReport() {
return {
@@ -206,13 +203,13 @@ class _CacheEntry {
final DateTime timestamp;
final Duration ttl;
DateTime lastAccess;
_CacheEntry({
required this.data,
required this.timestamp,
required this.ttl,
}) : lastAccess = timestamp;
bool get isExpired => DateTime.now().difference(timestamp) > ttl;
}
@@ -222,25 +219,26 @@ class ImageCacheStatus {
final int maximumSize;
final int currentSizeBytes;
final int maximumSizeBytes;
ImageCacheStatus({
required this.currentSize,
required this.maximumSize,
required this.currentSizeBytes,
required this.maximumSizeBytes,
});
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100;
double get bytesUsagePercentage =>
(currentSizeBytes / maximumSizeBytes) * 100;
Map<String, dynamic> toJson() => {
'currentSize': currentSize,
'maximumSize': maximumSize,
'currentSizeBytes': currentSizeBytes,
'maximumSizeBytes': maximumSizeBytes,
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
};
'currentSize': currentSize,
'maximumSize': maximumSize,
'currentSizeBytes': currentSizeBytes,
'maximumSizeBytes': maximumSizeBytes,
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
};
}
/// 메모리 효율적인 리스트 뷰
@@ -249,7 +247,7 @@ class MemoryEfficientListView<T> extends StatefulWidget {
final Widget Function(BuildContext, T) itemBuilder;
final int cacheExtent;
final ScrollPhysics? physics;
const MemoryEfficientListView({
super.key,
required this.items,
@@ -257,23 +255,21 @@ class MemoryEfficientListView<T> extends StatefulWidget {
this.cacheExtent = 250,
this.physics,
});
@override
State<MemoryEfficientListView<T>> createState() =>
State<MemoryEfficientListView<T>> createState() =>
_MemoryEfficientListViewState<T>();
}
class _MemoryEfficientListViewState<T>
extends State<MemoryEfficientListView<T>>
class _MemoryEfficientListViewState<T> extends State<MemoryEfficientListView<T>>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => false;
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
itemCount: widget.items.length,
cacheExtent: widget.cacheExtent.toDouble(),
@@ -283,4 +279,4 @@ class _MemoryEfficientListViewState<T>
},
);
}
}
}

View File

@@ -5,19 +5,20 @@ import 'dart:async';
/// 성능 최적화를 위한 유틸리티 클래스
class PerformanceOptimizer {
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal();
static final PerformanceOptimizer _instance =
PerformanceOptimizer._internal();
factory PerformanceOptimizer() => _instance;
PerformanceOptimizer._internal();
// 프레임 타이밍 정보
final List<FrameTiming> _frameTimings = [];
bool _isMonitoring = false;
/// 프레임 성능 모니터링 시작
void startFrameMonitoring() {
if (_isMonitoring) return;
_isMonitoring = true;
SchedulerBinding.instance.addTimingsCallback((timings) {
_frameTimings.addAll(timings);
// 최근 100개 프레임만 유지
@@ -26,27 +27,27 @@ class PerformanceOptimizer {
}
});
}
/// 프레임 성능 모니터링 중지
void stopFrameMonitoring() {
if (!_isMonitoring) return;
_isMonitoring = false;
SchedulerBinding.instance.addTimingsCallback((_) {});
}
/// 평균 FPS 계산
double getAverageFPS() {
if (_frameTimings.isEmpty) return 0.0;
double totalDuration = 0;
for (final timing in _frameTimings) {
totalDuration += timing.totalSpan.inMicroseconds;
}
final averageDuration = totalDuration / _frameTimings.length;
return 1000000 / averageDuration; // microseconds to FPS
}
/// 메모리 사용량 모니터링
static Future<MemoryInfo> getMemoryInfo() async {
// Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로
@@ -57,7 +58,7 @@ class PerformanceOptimizer {
capacity: imageCache.maximumSizeBytes,
);
}
/// 위젯 재빌드 최적화를 위한 데바운서
static Timer? _debounceTimer;
static void debounce(
@@ -67,7 +68,7 @@ class PerformanceOptimizer {
_debounceTimer?.cancel();
_debounceTimer = Timer(delay, callback);
}
/// 스로틀링 - 지정된 시간 간격으로만 실행
static DateTime? _lastThrottleTime;
static void throttle(
@@ -81,7 +82,7 @@ class PerformanceOptimizer {
callback();
}
}
/// 무거운 연산을 별도 Isolate에서 실행
static Future<T> runInIsolate<T>(
ComputeCallback<dynamic, T> callback,
@@ -89,7 +90,7 @@ class PerformanceOptimizer {
) async {
return await compute(callback, parameter);
}
/// 레이지 로딩을 위한 페이지네이션 헬퍼
static List<T> paginate<T>({
required List<T> items,
@@ -98,13 +99,14 @@ class PerformanceOptimizer {
}) {
final startIndex = page * pageSize;
final endIndex = (startIndex + pageSize).clamp(0, items.length);
if (startIndex >= items.length) return [];
return items.sublist(startIndex, endIndex);
}
/// 이미지 최적화 - 메모리 효율적인 크기로 조정
static double getOptimalImageSize(BuildContext context, {
static double getOptimalImageSize(
BuildContext context, {
required double originalSize,
double maxSize = 1000,
}) {
@@ -113,29 +115,29 @@ class PerformanceOptimizer {
final maxDimension = screenSize.width > screenSize.height
? screenSize.width
: screenSize.height;
final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize);
return optimalSize < originalSize ? optimalSize : originalSize;
}
/// 위젯 키 최적화
static Key generateOptimizedKey(String prefix, dynamic identifier) {
return ValueKey('${prefix}_$identifier');
}
/// 애니메이션 최적화 - 보이지 않는 애니메이션 중지
static bool shouldAnimateWidget(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation;
}
/// 스크롤 성능 최적화
static ScrollPhysics getOptimizedScrollPhysics() {
return const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
);
}
/// 빌드 최적화를 위한 const 위젯 권장사항 체크
static void checkConstOptimization() {
if (kDebugMode) {
@@ -147,16 +149,16 @@ class PerformanceOptimizer {
print('5. 애니메이션은 AnimatedBuilder 사용');
}
}
/// 메모리 누수 감지 헬퍼
static final Map<String, int> _widgetCounts = {};
static void trackWidget(String widgetName, bool isCreated) {
if (!kDebugMode) return;
_widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) +
(isCreated ? 1 : -1);
_widgetCounts[widgetName] =
(_widgetCounts[widgetName] ?? 0) + (isCreated ? 1 : -1);
// 위젯이 비정상적으로 많이 생성되면 경고
if ((_widgetCounts[widgetName] ?? 0) > 100) {
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
@@ -168,16 +170,18 @@ class PerformanceOptimizer {
class MemoryInfo {
final int currentUsage;
final int capacity;
MemoryInfo({
required this.currentUsage,
required this.capacity,
});
double get usagePercentage => (currentUsage / capacity) * 100;
String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedUsage =>
'${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedCapacity =>
'${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
}
/// 성능 측정 데코레이터
@@ -187,7 +191,7 @@ class PerformanceMeasure {
required Future<T> Function() operation,
}) async {
if (!kDebugMode) return await operation();
final stopwatch = Stopwatch()..start();
try {
final result = await operation();
@@ -200,4 +204,4 @@ class PerformanceMeasure {
rethrow;
}
}
}
}

View File

@@ -2,23 +2,23 @@ import 'package:flutter/foundation.dart';
class PlatformHelper {
static bool get isWeb => kIsWeb;
static bool get isIOS {
if (kIsWeb) return false;
return defaultTargetPlatform == TargetPlatform.iOS;
}
static bool get isAndroid {
if (kIsWeb) return false;
return defaultTargetPlatform == TargetPlatform.android;
}
static bool get isMobile => isIOS || isAndroid;
static bool get isDesktop {
if (kIsWeb) return false;
return defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows;
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows;
}
}
}

View File

@@ -50,4 +50,4 @@ class CategoryIconMapper {
return 16.0;
}
}
}
}

View File

@@ -26,7 +26,7 @@ class SmsDateFormatter {
) {
// 주기에 따라 다음 결제일 예측
DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now);
if (predictedDate != null) {
final daysUntil = predictedDate.difference(now).inDays;
return AppLocalizations.of(context).nextBillingDateEstimated(
@@ -34,7 +34,7 @@ class SmsDateFormatter {
daysUntil,
);
}
return '다음 결제일 확인 필요 (과거 날짜)';
}
@@ -78,7 +78,7 @@ class SmsDateFormatter {
// 다음 월간 결제일 계산
static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) {
int day = lastDate.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
@@ -101,7 +101,7 @@ class SmsDateFormatter {
// 다음 연간 결제일 계산
static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) {
int day = lastDate.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, lastDate.month + 1, 0).day;
if (day > lastDay) {
@@ -162,4 +162,4 @@ class SmsDateFormatter {
static String getRepeatCountText(BuildContext context, int count) {
return AppLocalizations.of(context).repeatCountDetected(count);
}
}
}

View File

@@ -86,8 +86,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'] = [];
}

View File

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

View File

@@ -66,7 +66,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: controller.gradientColors[0].withValues(alpha: 0.1),
color: controller.gradientColors[0]
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -122,7 +123,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
],
),
// 이벤트 활성화 시 추가 필드 표시
AnimatedContainer(
duration: const Duration(milliseconds: 300),
@@ -155,7 +156,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,7 +170,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
infoText = '设置折扣或促销价格';
break;
default:
infoText = 'Set up discount or promotion price';
infoText =
'Set up discount or promotion price';
}
return Text(
infoText,
@@ -185,7 +188,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
),
const SizedBox(height: 20),
// 이벤트 기간
Builder(
builder: (context) {
@@ -216,8 +219,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
setState(() {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
if (date != null &&
controller.eventEndDate == null) {
controller.eventEndDate =
date.add(const Duration(days: 30));
}
});
},
@@ -233,17 +238,18 @@ class AddSubscriptionEventSection extends StatelessWidget {
},
),
const SizedBox(height: 20),
// 이벤트 가격
Builder(
builder: (BuildContext innerContext) {
// 현재 로케일 확인
final currentLocale = Localizations.localeOf(innerContext);
final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정
String eventPriceLabel;
String eventPriceHint;
switch (currentLocale.languageCode) {
case 'ko':
eventPriceLabel = '이벤트 가격';
@@ -261,7 +267,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
eventPriceLabel = 'Event Price';
eventPriceHint = 'Enter discounted price';
}
return CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
@@ -280,4 +286,4 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
);
}
}
}

View File

@@ -86,4 +86,4 @@ class AddSubscriptionHeader extends StatelessWidget {
),
);
}
}
}

View File

@@ -40,8 +40,8 @@ class AddSubscriptionSaveButton extends StatelessWidget {
child: PrimaryButton(
text: AppLocalizations.of(context).addSubscriptionButton,
icon: Icons.add_circle_outline,
onPressed: controller.isLoading
? null
onPressed: controller.isLoading
? null
: () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6),
@@ -50,4 +50,4 @@ class AddSubscriptionSaveButton extends StatelessWidget {
),
);
}
}
}

View File

@@ -69,13 +69,17 @@ class AnalysisBadge extends StatelessWidget {
String displayText = amountText;
if (amountText.length > 12) {
// 괄호 안의 내용 제거
displayText = amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
displayText =
amountText.replaceAll(RegExp(r'\([^)]*\)'), '').trim();
}
if (displayText.length > 10) {
// 통화 기호만 남기고 숫자만 표시
final currencySymbol = CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText = displayText.replaceAll(currencySymbol, '').trim();
displayText = '$currencySymbol${displayText.substring(0, 6)}...';
final currencySymbol =
CurrencyUtil.getCurrencySymbol(subscription.currency);
displayText =
displayText.replaceAll(currencySymbol, '').trim();
displayText =
'$currencySymbol${displayText.substring(0, 6)}...';
}
return Text(
displayText,
@@ -93,4 +97,4 @@ class AnalysisBadge extends StatelessWidget {
),
);
}
}
}

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
class AnalysisScreenSpacer extends StatelessWidget {
final double height;
const AnalysisScreenSpacer({
super.key,
this.height = 24,
@@ -16,4 +16,4 @@ class AnalysisScreenSpacer extends StatelessWidget {
child: SizedBox(height: height),
);
}
}
}

View File

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

View File

@@ -23,7 +23,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
/// Y축 최대값을 계산합니다 (언어별 통화 단위에 맞춰)
double _calculateChartMaxY(double maxValue, String locale) {
final currency = CurrencyUtil.getDefaultCurrency(locale);
if (currency == 'KRW' || currency == 'JPY') {
// 소수점이 없는 통화 (원화, 엔화)
if (maxValue <= 0) return 100000;
@@ -33,9 +33,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
if (maxValue <= 200000) return 200000;
if (maxValue <= 500000) return 500000;
if (maxValue <= 1000000) return 1000000;
// 큰 금액은 자릿수에 맞춰 반올림
final magnitude = math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
final magnitude =
math.pow(10, maxValue.toString().split('.')[0].length - 1).toDouble();
return ((maxValue / magnitude).ceil() * magnitude).toDouble();
} else {
// 소수점이 있는 통화 (달러, 위안)
@@ -47,7 +48,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
if (maxValue <= 250) return 250.0;
if (maxValue <= 500) return 500.0;
if (maxValue <= 1000) return 1000.0;
// 큰 금액은 100 단위로 반올림
return ((maxValue / 100).ceil() * 100).toDouble();
}
@@ -164,8 +165,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 +176,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,10 +232,11 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
children: [
TextSpan(
text: CurrencyUtil.formatTotalAmountWithLocale(
monthlyData[group.x]['totalExpense']
as double,
locale),
text: CurrencyUtil
.formatTotalAmountWithLocale(
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 14,
@@ -254,7 +254,8 @@ class MonthlyExpenseChartCard extends StatelessWidget {
const SizedBox(height: 16),
Center(
child: ThemedText.caption(
text: AppLocalizations.of(context).monthlySubscriptionExpense,
text: AppLocalizations.of(context)
.monthlySubscriptionExpense,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -270,4 +271,4 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -23,14 +23,15 @@ class SubscriptionPieChartCard extends StatefulWidget {
});
@override
State<SubscriptionPieChartCard> createState() => _SubscriptionPieChartCardState();
State<SubscriptionPieChartCard> createState() =>
_SubscriptionPieChartCardState();
}
class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
int _touchedIndex = -1;
late Future<List<PieChartSectionData>> _pieSectionsFuture;
String? _lastLocale;
static const _chartColors = [
Color(0xFF3B82F6),
Color(0xFF10B981),
@@ -52,7 +53,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
super.didUpdateWidget(oldWidget);
// subscriptions나 locale이 변경된 경우만 Future 재생성
final currentLocale = context.read<LocaleProvider>().locale.languageCode;
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
if (!_listEquals(oldWidget.subscriptions, widget.subscriptions) ||
_lastLocale != currentLocale) {
_initializeFuture();
}
@@ -66,7 +67,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
bool _listEquals(List<SubscriptionModel> a, List<SubscriptionModel> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i].id != b[i].id ||
if (a[i].id != b[i].id ||
a[i].currentPrice != b[i].currentPrice ||
a[i].currency != b[i].currency ||
a[i].serviceName != b[i].serviceName) {
@@ -78,7 +79,6 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 파이 차트 섹션 데이터 (언어별 기본 통화로 환산)
Future<List<PieChartSectionData>> _getPieSections() async {
if (widget.subscriptions.isEmpty) return [];
// 현재 locale 가져오기
@@ -91,17 +91,19 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 각 구독의 현재 가격을 언어별 기본 통화로 환산
for (var subscription in widget.subscriptions) {
double value = subscription.currentPrice;
if (subscription.currency == defaultCurrency) {
// 이미 기본 통화인 경우 그대로 사용
sectionValues.add(value);
} else if (subscription.currency == 'USD') {
// USD를 기본 통화로 변환
final converted = await ExchangeRateService().convertUsdToTarget(value, defaultCurrency);
final converted = await ExchangeRateService()
.convertUsdToTarget(value, defaultCurrency);
sectionValues.add(converted ?? value);
} else if (defaultCurrency == 'USD') {
// 기본 통화가 USD인 경우 다른 통화를 USD로 변환
final converted = await ExchangeRateService().convertTargetToUsd(value, subscription.currency);
final converted = await ExchangeRateService()
.convertTargetToUsd(value, subscription.currency);
sectionValues.add(converted ?? value);
} else {
// 기타 통화는 일단 그대로 사용 (환율 정보가 없는 경우)
@@ -111,7 +113,7 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 총합 계산
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
// 총합이 0이면 빈 배열 반환
if (sectionsTotal == 0) return [];
@@ -138,17 +140,17 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
badgePositionPercentageOffset: .98,
);
});
return sections;
}
// 배지 위젯 생성
Widget _createBadgeWidget(int index) {
if (index >= widget.subscriptions.length) return const SizedBox.shrink();
final subscription = widget.subscriptions[index];
final colorIndex = index % _chartColors.length;
return IgnorePointer(
child: AnalysisBadge(
size: 40,
@@ -159,24 +161,27 @@ 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;
final fontSize = isTouched ? 16.0 : 12.0;
final radius = isTouched ? 105.0 : 100.0;
return PieChartSectionData(
value: section.value,
title: section.title,
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ?? TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
shadows: const [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
titleStyle: section.titleStyle?.copyWith(fontSize: fontSize) ??
TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: AppColors.pureWhite,
shadows: const [
Shadow(
color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: section.color,
radius: radius,
titlePositionPercentageOffset: section.titlePositionPercentageOffset,
@@ -217,18 +222,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 +243,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 +279,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,36 +292,41 @@ 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,
),
),
);
}
return 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(() {
@@ -322,22 +335,25 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
}
return;
}
final touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
// 탭 이벤트 처리 (토글)
if (event is FlTapUpEvent) {
setState(() {
// 동일 섹션 탭하면 선택 해제, 아니면 선택
_touchedIndex = (_touchedIndex == touchedIndex) ? -1 : touchedIndex;
_touchedIndex = (_touchedIndex ==
touchedIndex)
? -1
: touchedIndex;
});
return;
}
// hover 이벤트 처리 (단순 표시)
if (event is FlPointerHoverEvent ||
if (event is FlPointerHoverEvent ||
event is FlPointerEnterEvent) {
// 현재 인덱스와 다른 경우만 업데이트
if (_touchedIndex != touchedIndex) {
@@ -364,10 +380,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 +401,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,
),
);
@@ -430,4 +446,4 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
),
);
}
}
}

View File

@@ -56,7 +56,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 +68,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,
@@ -115,7 +120,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 +140,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 +160,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 +169,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 +187,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 +207,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,11 +216,13 @@ class TotalExpenseSummaryCard extends StatelessWidget {
),
const SizedBox(height: 2),
ThemedText(
CurrencyUtil.formatTotalAmountWithLocale(
subscriptions.isEmpty
? 0
: totalExpense / subscriptions.length,
locale),
CurrencyUtil
.formatTotalAmountWithLocale(
subscriptions.isEmpty
? 0
: totalExpense /
subscriptions.length,
locale),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -230,4 +246,4 @@ class TotalExpenseSummaryCard extends StatelessWidget {
),
);
}
}
}

View File

@@ -5,7 +5,7 @@ import 'dart:math' as math;
class SlidePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final AxisDirection direction;
SlidePageRoute({
required this.page,
this.direction = AxisDirection.right,
@@ -29,20 +29,20 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
begin = const Offset(0.0, -1.0);
break;
}
const end = Offset.zero;
const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var offsetAnimation = animation.drive(tween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var fadeAnimation = animation.drive(fadeTween);
return SlideTransition(
position: offsetAnimation,
child: FadeTransition(
@@ -58,7 +58,7 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
class ScalePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final Alignment alignment;
ScalePageRoute({
required this.page,
this.alignment = Alignment.center,
@@ -68,17 +68,17 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.elasticOut;
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var scaleAnimation = animation.drive(scaleTween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: Curves.easeIn),
);
var fadeAnimation = animation.drive(fadeTween);
return ScaleTransition(
scale: scaleAnimation,
alignment: alignment,
@@ -94,7 +94,7 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
/// 회전 + 스케일 전환
class RotatePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
RotatePageRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
@@ -102,17 +102,17 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
reverseTransitionDuration: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.easeInOut;
var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
CurveTween(curve: curve),
);
var rotateAnimation = animation.drive(rotateTween);
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var scaleAnimation = animation.drive(scaleTween);
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
@@ -129,7 +129,7 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
class FlipPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final bool horizontal;
FlipPageRoute({
required this.page,
this.horizontal = true,
@@ -138,8 +138,9 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
transitionDuration: const Duration(milliseconds: 800),
reverseTransitionDuration: const Duration(milliseconds: 800),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final isAnimatingForward = animation.status == AnimationStatus.forward;
final isAnimatingForward =
animation.status == AnimationStatus.forward;
final flipAnimation = Tween(
begin: 0.0,
end: isAnimatingForward ? -math.pi : math.pi,
@@ -147,12 +148,12 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
parent: animation,
curve: Curves.easeInOut,
));
return AnimatedBuilder(
animation: flipAnimation,
builder: (context, child) {
final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
@@ -181,7 +182,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final Widget startWidget;
final BorderRadius? borderRadius;
ContainerTransformPageRoute({
required this.page,
required this.startWidget,
@@ -208,7 +209,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
final scale = 0.5 + (0.5 * progress);
final radius = borderRadius?.topLeft.x ?? 0;
final currentRadius = radius * (1 - progress);
return Transform.scale(
scale: scale,
child: ClipRRect(
@@ -229,7 +230,7 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final String heroTag;
CustomHeroPageRoute({
required this.page,
required this.heroTag,
@@ -253,7 +254,7 @@ class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final SharedAxisTransitionType transitionType;
SharedAxisPageRoute({
required this.page,
required this.transitionType,
@@ -264,7 +265,7 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
transitionsBuilder: (context, animation, secondaryAnimation, child) {
late final Offset begin;
late final Offset end;
switch (transitionType) {
case SharedAxisTransitionType.horizontal:
begin = const Offset(1.0, 0.0);
@@ -279,17 +280,17 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
end = Offset.zero;
break;
}
final slideTween = Tween(begin: begin, end: end);
final fadeTween = Tween(begin: 0.0, end: 1.0);
final scaleTween = transitionType == SharedAxisTransitionType.scaled
? Tween(begin: 0.8, end: 1.0)
: Tween(begin: 1.0, end: 1.0);
final slideAnimation = animation.drive(slideTween);
final fadeAnimation = animation.drive(fadeTween);
final scaleAnimation = animation.drive(scaleTween);
return SlideTransition(
position: slideAnimation,
child: FadeTransition(
@@ -308,4 +309,4 @@ enum SharedAxisTransitionType {
horizontal,
vertical,
scaled,
}
}

View File

@@ -109,8 +109,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: 0.1 + 0.1 * pulseController.value,
),
borderRadius: BorderRadius.circular(15),
),

View File

@@ -18,7 +18,7 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.clearHistoryAndGoHome();
await Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main,
(route) => false,
@@ -30,22 +30,23 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(1);
await Navigator.of(context).pushNamed(AppRoutes.analysis);
}
/// 구독 추가 화면으로 네비게이션
static Future<void> toAddSubscription(BuildContext context) async {
HapticFeedback.mediumImpact();
await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
}
/// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
static Future<void> toDetail(
BuildContext context, SubscriptionModel subscription) async {
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact();
try {
await Navigator.of(context).pushNamed(
AppRoutes.subscriptionDetail,
@@ -62,7 +63,7 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(3);
await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
}
@@ -71,14 +72,14 @@ class AppNavigator {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(4);
await Navigator.of(context).pushNamed(AppRoutes.settings);
}
/// 카테고리 관리 화면으로 네비게이션
static Future<void> toCategoryManagement(BuildContext context) async {
HapticFeedback.lightImpact();
await Navigator.of(context).push(
SlidePageRoute(
page: const CategoryManagementScreen(),
@@ -101,20 +102,20 @@ class AppNavigator {
static Future<bool> handleBackButton(BuildContext context) async {
final navigator = Navigator.of(context);
final navigationProvider = context.read<NavigationProvider>();
// 네비게이션 스택이 있으면 팝
if (navigator.canPop()) {
HapticFeedback.lightImpact();
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
if (navigationProvider.canPop()) {
navigationProvider.pop();
}
navigator.pop();
return false;
}
// 앱 종료 확인
final shouldExit = await showDialog<bool>(
context: context,
@@ -133,7 +134,7 @@ class AppNavigator {
],
),
);
return shouldExit ?? false;
}
@@ -141,17 +142,17 @@ class AppNavigator {
static void handleFloatingNavTap(BuildContext context, int index) {
final navigationProvider = context.read<NavigationProvider>();
final currentIndex = navigationProvider.currentIndex;
// 같은 탭을 다시 탭하면 아무 동작 안 함
if (currentIndex == index) {
return;
}
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
if (Navigator.of(context).canPop()) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
// 선택된 인덱스에 따라 네비게이션
switch (index) {
case 0: // 홈
@@ -196,6 +197,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}');
}
}
}

View File

@@ -66,13 +66,14 @@ class CategoryHeaderWidget extends StatelessWidget {
/// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay(BuildContext context) {
final parts = <String>[];
// 개수는 항상 표시
parts.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
parts
.add(AppLocalizations.of(context).subscriptionCount(subscriptionCount));
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
if (totalCostUSD > 0) {
final formatter = NumberFormat.currency(
@@ -82,7 +83,7 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostUSD));
}
// 원화가 있는 경우
if (totalCostKRW > 0) {
final formatter = NumberFormat.currency(
@@ -92,7 +93,7 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostKRW));
}
// 엔화가 있는 경우
if (totalCostJPY > 0) {
final formatter = NumberFormat.currency(
@@ -102,7 +103,7 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostJPY));
}
// 위안화가 있는 경우
if (totalCostCNY > 0) {
final formatter = NumberFormat.currency(
@@ -112,14 +113,14 @@ class CategoryHeaderWidget extends StatelessWidget {
);
currencyParts.add(formatter.format(totalCostCNY));
}
// 통화가 하나 이상 있는 경우
if (currencyParts.isNotEmpty) {
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
final currencyDisplay = currencyParts.join(' + ');
parts.add(currencyDisplay);
}
return parts.join(' · ');
}
}

View File

@@ -39,7 +39,7 @@ class DangerButton extends StatefulWidget {
class _DangerButtonState extends State<DangerButton> {
bool _isHovered = false;
static const Color _dangerColor = AppColors.dangerColor;
Future<void> _handlePress() async {
@@ -74,8 +74,7 @@ class _DangerButtonState extends State<DangerButton> {
),
const SizedBox(height: 16),
Text(
widget.confirmationMessage ??
'이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
widget.confirmationMessage ?? '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
@@ -171,4 +170,4 @@ class _DangerButtonState extends State<DangerButton> {
return button;
}
}
}

View File

@@ -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(
@@ -110,4 +113,4 @@ class _PrimaryButtonState extends State<PrimaryButton> {
return button;
}
}
}

View File

@@ -61,18 +61,18 @@ class _SecondaryButtonState extends State<SecondaryButton> {
borderRadius: BorderRadius.circular(widget.borderRadius),
),
side: BorderSide(
color: _isHovered
color: _isHovered
? effectiveBorderColor.withValues(alpha: 0.4)
: effectiveBorderColor,
width: widget.borderWidth,
),
padding: widget.padding ?? const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
backgroundColor: _isHovered
? AppColors.glassBackground
: Colors.transparent,
padding: widget.padding ??
const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
backgroundColor:
_isHovered ? AppColors.glassBackground : Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -146,7 +146,7 @@ class _TextLinkButtonState extends State<TextLinkButton> {
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isHovered
color: _isHovered
? theme.colorScheme.onSurface.withValues(alpha: 0.05)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
@@ -179,9 +179,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,
),
),
],
@@ -199,4 +198,4 @@ class _TextLinkButtonState extends State<TextLinkButton> {
return button;
}
}
}

View File

@@ -34,13 +34,14 @@ class SectionCard extends StatelessWidget {
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),
),
];
final effectiveShadow = boxShadow ??
[
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
];
Widget card = Container(
height: height,
@@ -226,4 +227,4 @@ class InfoCard extends StatelessWidget {
),
);
}
}
}

View File

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

View File

@@ -193,7 +193,8 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
width: widget.size / 5,
height: widget.size / 5,
decoration: BoxDecoration(
color: effectiveColor.withValues(alpha: 0.3 + value * 0.7),
color:
effectiveColor.withValues(alpha: 0.3 + value * 0.7),
shape: BoxShape.circle,
),
);
@@ -220,7 +221,8 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
height: widget.size * (0.3 + _animation.value * 0.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withValues(alpha: 1 - _animation.value),
color:
effectiveColor.withValues(alpha: 1 - _animation.value),
),
),
),
@@ -235,4 +237,4 @@ enum LoadingStyle {
circular,
dots,
pulse,
}
}

View File

@@ -59,7 +59,7 @@ class BaseTextField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -90,10 +90,11 @@ class BaseTextField extends StatelessWidget {
minLines: minLines,
readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
),
style: style ??
TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
@@ -146,4 +147,4 @@ class BaseTextField extends StatelessWidget {
],
);
}
}
}

View File

@@ -24,7 +24,7 @@ class BillingCycleSelector extends StatelessWidget {
Widget build(BuildContext context) {
final localization = AppLocalizations.of(context);
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
final cycles = isGlassmorphism
final cycles = isGlassmorphism
? [
localization.billingCycleMonthly,
localization.billingCycleQuarterly,
@@ -37,7 +37,7 @@ class BillingCycleSelector extends StatelessWidget {
localization.billingCycleHalfYearly,
localization.yearly,
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@@ -76,7 +76,7 @@ class BillingCycleSelector extends StatelessWidget {
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
@@ -84,11 +84,11 @@ class BillingCycleSelector extends StatelessWidget {
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
@@ -106,8 +106,6 @@ class BillingCycleSelector extends StatelessWidget {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
}
}
}

View File

@@ -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,
@@ -101,7 +102,7 @@ class CategorySelector extends StatelessWidget {
Color _getBackgroundColor(bool isSelected) {
if (!isSelected) {
return isGlassmorphism
return isGlassmorphism
? AppColors.backgroundColor
: Colors.grey.withValues(alpha: 0.1);
}
@@ -109,11 +110,11 @@ class CategorySelector extends StatelessWidget {
if (baseColor != null) {
return baseColor!;
}
if (gradientColors != null && gradientColors!.isNotEmpty) {
return gradientColors![0];
}
return const Color(0xFF3B82F6);
}
@@ -131,8 +132,6 @@ class CategorySelector extends StatelessWidget {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.darkNavy
: Colors.grey[700]!;
return isGlassmorphism ? AppColors.darkNavy : Colors.grey[700]!;
}
}
}

View File

@@ -45,7 +45,7 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onFocusChanged);
// 초기값이 있으면 포맷팅 적용
if (widget.controller.text.isNotEmpty) {
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
@@ -105,7 +105,11 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
}
double? _parseValue(String text) {
final cleanText = text.replaceAll(',', '').replaceAll('', '').replaceAll('\$', '').trim();
final cleanText = text
.replaceAll(',', '')
.replaceAll('', '')
.replaceAll('\$', '')
.trim();
return double.tryParse(cleanText);
}
@@ -128,16 +132,13 @@ class _CurrencyInputFieldState extends State<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) {
final text = newValue.text;
if (text.isEmpty) return newValue;
final parts = text.split('.');
if (parts.length > 2) {
// 소수점이 2개 이상인 경우 거부
@@ -157,16 +158,17 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
final parsedValue = _parseValue(value);
widget.onChanged?.call(parsedValue);
},
validator: widget.validator ?? (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).amountRequired;
}
final parsedValue = _parseValue(value);
if (parsedValue == null || parsedValue <= 0) {
return AppLocalizations.of(context).invalidAmount;
}
return null;
},
validator: widget.validator ??
(value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).amountRequired;
}
final parsedValue = _parseValue(value);
if (parsedValue == null || parsedValue <= 0) {
return AppLocalizations.of(context).invalidAmount;
}
return null;
},
);
}
}
}

View File

@@ -86,7 +86,7 @@ class _CurrencyOption extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Expanded(
child: InkWell(
onTap: onTap,
@@ -131,11 +131,9 @@ class _CurrencyOption extends StatelessWidget {
Color _getBackgroundColor(ThemeData theme) {
if (isSelected) {
return isGlassmorphism
? theme.primaryColor
: const Color(0xFF3B82F6);
return isGlassmorphism ? theme.primaryColor : const Color(0xFF3B82F6);
}
return isGlassmorphism
return isGlassmorphism
? AppColors.surfaceColorAlt
: Colors.grey.withValues(alpha: 0.1);
}
@@ -154,8 +152,6 @@ class _CurrencyOption extends StatelessWidget {
if (isSelected) {
return Colors.white;
}
return isGlassmorphism
? AppColors.navyGray
: Colors.grey[600]!;
return isGlassmorphism ? AppColors.navyGray : Colors.grey[600]!;
}
}
}

View File

@@ -42,7 +42,7 @@ class DatePickerField extends StatelessWidget {
final localizations = AppLocalizations.of(context);
final effectiveDateFormat = dateFormat ?? localizations.dateFormatFull;
final locale = Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -57,31 +57,35 @@ class DatePickerField extends StatelessWidget {
const SizedBox(height: 8),
InkWell(
focusNode: focusNode,
onTap: enabled ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null && picked != selectedDate) {
onDateSelected(picked);
}
} : null,
onTap: enabled
? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: firstDate ??
DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: lastDate ??
DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null && picked != selectedDate) {
onDateSelected(picked);
}
}
: null,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: contentPadding ?? const EdgeInsets.all(16),
@@ -97,21 +101,19 @@ class DatePickerField extends StatelessWidget {
children: [
Expanded(
child: Text(
DateFormat(effectiveDateFormat, locale.toString()).format(selectedDate),
DateFormat(effectiveDateFormat, locale.toString())
.format(selectedDate),
style: TextStyle(
fontSize: 16,
color: enabled
? AppColors.textPrimary
: AppColors.textMuted,
color:
enabled ? AppColors.textPrimary : AppColors.textMuted,
),
),
),
Icon(
Icons.calendar_today,
size: 20,
color: enabled
? AppColors.navyGray
: AppColors.textMuted,
color: enabled ? AppColors.navyGray : AppColors.textMuted,
),
],
),
@@ -158,7 +160,8 @@ class DateRangePickerField extends StatelessWidget {
primaryColor: primaryColor,
onDateSelected: onStartDateSelected,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
lastDate:
endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
),
),
const SizedBox(width: 12),
@@ -203,31 +206,33 @@ class _DateRangeItem extends StatelessWidget {
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
return InkWell(
onTap: enabled ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: date ?? DateTime.now(),
firstDate: firstDate,
lastDate: lastDate,
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
onDateSelected(picked);
}
} : null,
onTap: enabled
? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: date ?? DateTime.now(),
firstDate: firstDate,
lastDate: lastDate,
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
onDateSelected(picked);
}
}
: null,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
@@ -252,14 +257,14 @@ class _DateRangeItem extends StatelessWidget {
const SizedBox(height: 4),
Text(
date != null
? DateFormat(AppLocalizations.of(context).dateFormatShort).format(date!)
? DateFormat(AppLocalizations.of(context).dateFormatShort)
.format(date!)
: AppLocalizations.of(context).dateSelect,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: date != null
? AppColors.textPrimary
: AppColors.textMuted,
color:
date != null ? AppColors.textPrimary : AppColors.textMuted,
),
),
],
@@ -267,4 +272,4 @@ class _DateRangeItem extends StatelessWidget {
),
);
}
}
}

View File

@@ -269,4 +269,4 @@ class AppSnackBar {
),
);
}
}
}

View File

@@ -44,4 +44,4 @@ class DetailActionButtons extends StatelessWidget {
),
);
}
}
}

View File

@@ -27,172 +27,177 @@ class DetailEventSection extends StatelessWidget {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.8),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.8),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.local_offer_rounded,
color: baseColor,
size: 24,
),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.local_offer_rounded,
color: baseColor,
size: 24,
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).eventPrice,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
),
),
],
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).eventPrice,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
),
// 이벤트 활성화 스위치
Switch.adaptive(
value: controller.isEventActive,
onChanged: (value) {
controller.isEventActive = value;
if (!value) {
// 이벤트 비활성화시 관련 정보 초기화
controller.eventStartDate = null;
controller.eventEndDate = null;
controller.eventPriceController.clear();
}
},
activeColor: baseColor,
),
],
),
// 이벤트 활성화 스위치
Switch.adaptive(
value: controller.isEventActive,
onChanged: (value) {
controller.isEventActive = value;
if (!value) {
// 이벤트 비활성화시 관련 정보 초기화
controller.eventStartDate = null;
controller.eventEndDate = null;
controller.eventPriceController.clear();
}
},
activeColor: baseColor,
),
// 이벤트 활성화시 표시될 필드들
if (controller.isEventActive) ...[
const SizedBox(height: 20),
// 이벤트 설명
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).eventPriceHint,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate =
date.add(const Duration(days: 30));
}
},
onEndDateSelected: (date) {
controller.eventEndDate = date;
},
startLabel: AppLocalizations.of(context).startDate,
endLabel: AppLocalizations.of(context).endDate,
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 이벤트 가격
CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: AppLocalizations.of(context).eventPrice,
hintText: AppLocalizations.of(context).eventPriceHint,
validator: controller.isEventActive
? (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context)
.eventPriceRequired;
}
final price =
double.tryParse(value.replaceAll(',', ''));
if (price == null || price <= 0) {
return AppLocalizations.of(context)
.invalidPrice;
}
return null;
}
: null,
),
const SizedBox(height: 16),
// 할인율 표시
if (controller.eventPriceController.text.isNotEmpty)
_DiscountBadge(
originalPrice: controller.subscription.monthlyCost,
eventPrice: double.tryParse(controller
.eventPriceController.text
.replaceAll(',', '')) ??
0,
currency: controller.currency,
),
],
],
),
// 이벤트 활성화시 표시될 필드들
if (controller.isEventActive) ...[
const SizedBox(height: 20),
// 이벤트 설명
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).eventPriceHint,
style: TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
}
},
onEndDateSelected: (date) {
controller.eventEndDate = date;
},
startLabel: AppLocalizations.of(context).startDate,
endLabel: AppLocalizations.of(context).endDate,
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 이벤트 가격
CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: AppLocalizations.of(context).eventPrice,
hintText: AppLocalizations.of(context).eventPriceHint,
validator: controller.isEventActive
? (value) {
if (value == null || value.isEmpty) {
return AppLocalizations.of(context).eventPriceRequired;
}
final price = double.tryParse(value.replaceAll(',', ''));
if (price == null || price <= 0) {
return AppLocalizations.of(context).invalidPrice;
}
return null;
}
: null,
),
const SizedBox(height: 16),
// 할인율 표시
if (controller.eventPriceController.text.isNotEmpty)
_DiscountBadge(
originalPrice: controller.subscription.monthlyCost,
eventPrice: double.tryParse(
controller.eventPriceController.text.replaceAll(',', '')
) ?? 0,
currency: controller.currency,
),
],
],
),
),
),
),
),
);
);
},
);
}
@@ -216,7 +221,8 @@ class _DiscountBadge extends StatelessWidget {
return const SizedBox.shrink();
}
final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round();
final discountPercentage =
((originalPrice - eventPrice) / originalPrice * 100).round();
final discountAmount = originalPrice - eventPrice;
return Container(
@@ -234,7 +240,9 @@ class _DiscountBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
AppLocalizations.of(context).discountPercent.replaceAll('@', discountPercentage.toString()),
AppLocalizations.of(context)
.discountPercent
.replaceAll('@', discountPercentage.toString()),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
@@ -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));
}
}
}
}

View File

@@ -32,151 +32,111 @@ class DetailFormSection extends StatelessWidget {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.6),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.6),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 서비스명 필드
BaseTextField(
controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus,
label: AppLocalizations.of(context).subscriptionName,
hintText: AppLocalizations.of(context).serviceNameExample,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.monthlyCostFocus.requestFocus();
},
),
const SizedBox(height: 20),
// 월 지출 및 통화 선택
Row(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
flex: 2,
child: CurrencyInputField(
controller: controller.monthlyCostController,
currency: controller.currency,
label: AppLocalizations.of(context).monthlyExpense,
focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.billingCycleFocus.requestFocus();
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).currency,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
CurrencySelector(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
controller.currency = value;
// 통화 변경시 금액 포맷 업데이트
if (value == 'KRW') {
final amount = double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '')
);
if (amount != null) {
controller.monthlyCostController.text =
amount.toInt().toString();
}
}
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 주기
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (value) {
controller.billingCycle = value;
// 서비스명 필드
BaseTextField(
controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus,
label: AppLocalizations.of(context).subscriptionName,
hintText: AppLocalizations.of(context).serviceNameExample,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.monthlyCostFocus.requestFocus();
},
),
],
),
const SizedBox(height: 20),
const SizedBox(height: 20),
// 다음 결제일
DatePickerField(
selectedDate: controller.nextBillingDate,
onDateSelected: (date) {
controller.nextBillingDate = date;
},
label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 월 지출 및 통화 선택
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: CurrencyInputField(
controller: controller.monthlyCostController,
currency: controller.currency,
label: AppLocalizations.of(context).monthlyExpense,
focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.billingCycleFocus.requestFocus();
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).currency,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
CurrencySelector(
currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) {
controller.currency = value;
// 통화 변경시 금액 포맷 업데이트
if (value == 'KRW') {
final amount = double.tryParse(controller
.monthlyCostController.text
.replaceAll(',', ''));
if (amount != null) {
controller.monthlyCostController.text =
amount.toInt().toString();
}
}
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 카테고리 선택
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Column(
// 결제 주기
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).category,
AppLocalizations.of(context).billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
@@ -184,27 +144,67 @@ class DetailFormSection extends StatelessWidget {
),
),
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
onChanged: (value) {
controller.billingCycle = value;
},
),
],
);
},
),
const SizedBox(height: 20),
// 다음 결제일
DatePickerField(
selectedDate: controller.nextBillingDate,
onDateSelected: (date) {
controller.nextBillingDate = date;
},
label: AppLocalizations.of(context).nextBillingDate,
firstDate: DateTime.now(),
lastDate:
DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 카테고리 선택
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).category,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 8),
CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
},
),
],
);
},
),
],
),
],
),
),
),
),
),
);
);
},
);
}
}

View File

@@ -34,191 +34,215 @@ class DetailHeaderSection extends StatelessWidget {
final gradient = controller.getGradient(baseColor);
return Container(
height: 320,
decoration: BoxDecoration(gradient: gradient),
child: Stack(
children: [
// 배경 패턴
Positioned(
top: -50,
right: -50,
child: RotationTransition(
turns: rotateAnimation,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.1),
height: 320,
decoration: BoxDecoration(gradient: gradient),
child: Stack(
children: [
// 배경 패턴
Positioned(
top: -50,
right: -50,
child: RotationTransition(
turns: rotateAnimation,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.1),
),
),
),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.08),
),
),
),
// 콘텐츠
SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: Colors.white,
),
onPressed: () => Navigator.of(context).pop(),
),
IconButton(
icon: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
),
onPressed: controller.deleteSubscription,
),
],
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.08),
),
const Spacer(),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
),
),
// 콘텐츠
SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 서비스 아이콘과 이름
Row(
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: Colors.white,
),
onPressed: () => Navigator.of(context).pop(),
),
IconButton(
icon: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
),
onPressed: controller.deleteSubscription,
),
],
),
const Spacer(),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'icon_${subscription.id}',
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: WebsiteIcon(
url: controller.websiteUrlController.text,
serviceName: controller.serviceNameController.text,
size: 48,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.displayName ?? controller.serviceNameController.text,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
// 서비스 아이콘과 이름
Row(
children: [
Hero(
tag: 'icon_${subscription.id}',
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
letterSpacing: -0.5,
shadows: [
Shadow(
color: Colors.black26,
offset: Offset(0, 2),
blurRadius: 4,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black
.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context).billingCyclePayment.replaceAll('@',
_getLocalizedBillingCycle(context, controller.billingCycle)),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: WebsiteIcon(
url: controller
.websiteUrlController.text,
serviceName: controller
.serviceNameController.text,
size: 48,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
controller.displayName ??
controller
.serviceNameController.text,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
shadows: [
Shadow(
color: Colors.black26,
offset: Offset(0, 2),
blurRadius: 4,
),
],
),
),
const SizedBox(height: 4),
Text(
AppLocalizations.of(context)
.billingCyclePayment
.replaceAll(
'@',
_getLocalizedBillingCycle(
context,
controller.billingCycle)),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white
.withValues(alpha: 0.8),
),
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 정보 카드
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
label: AppLocalizations.of(context)
.nextBillingDate,
value: AppLocalizations.of(context)
.formatDate(
controller.nextBillingDate),
),
FutureBuilder<String>(
future: () async {
final locale = context
.read<LocaleProvider>()
.locale
.languageCode;
final amount = double.tryParse(
controller
.monthlyCostController.text
.replaceAll(',', '')) ??
0;
return CurrencyUtil
.formatAmountWithLocale(
amount,
controller.currency,
locale,
);
}(),
builder: (context, snapshot) {
return _InfoColumn(
label: AppLocalizations.of(context)
.monthlyExpense,
value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end,
);
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 정보 카드
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
label: AppLocalizations.of(context).nextBillingDate,
value: AppLocalizations.of(context).formatDate(controller.nextBillingDate),
),
FutureBuilder<String>(
future: () async {
final locale = context.read<LocaleProvider>().locale.languageCode;
final amount = double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '')
) ?? 0;
return CurrencyUtil.formatAmountWithLocale(
amount,
controller.currency,
locale,
);
}(),
builder: (context, snapshot) {
return _InfoColumn(
label: AppLocalizations.of(context).monthlyExpense,
value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end,
);
},
),
],
),
),
],
),
),
),
],
),
],
),
),
),
],
),
],
),
);
);
},
);
}
String _getLocalizedBillingCycle(BuildContext context, String cycle) {
final loc = AppLocalizations.of(context);
switch (cycle.toLowerCase()) {
@@ -285,4 +309,4 @@ class _InfoColumn extends StatelessWidget {
],
);
}
}
}

View File

@@ -81,7 +81,7 @@ class DetailUrlSection extends StatelessWidget {
],
),
const SizedBox(height: 20),
// URL 입력 필드
BaseTextField(
controller: controller.websiteUrlController,
@@ -94,7 +94,7 @@ class DetailUrlSection extends StatelessWidget {
color: AppColors.navyGray,
),
),
// 해지 안내 섹션
if (controller.subscription.websiteUrl != null &&
controller.subscription.websiteUrl!.isNotEmpty) ...[
@@ -151,7 +151,7 @@ class DetailUrlSection extends StatelessWidget {
),
),
],
// URL 자동 매칭 정보
if (controller.websiteUrlController.text.isEmpty) ...[
const SizedBox(height: 16),
@@ -194,4 +194,4 @@ class DetailUrlSection extends StatelessWidget {
),
);
}
}
}

View File

@@ -56,7 +56,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 24),
// 타이틀
const Text(
'구독 삭제',
@@ -67,7 +67,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 12),
// 설명
RichText(
textAlign: TextAlign.center,
@@ -91,7 +91,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 8),
// 경고 메시지
Container(
padding: const EdgeInsets.symmetric(
@@ -127,7 +127,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
@@ -176,7 +176,7 @@ class DeleteConfirmationDialog extends StatelessWidget {
serviceName: serviceName,
),
);
return result ?? false;
}
}
}

View File

@@ -58,7 +58,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),

View File

@@ -7,7 +7,7 @@ import 'glassmorphism_card.dart';
class ExpandableFab extends StatefulWidget {
final List<FabAction> actions;
final double distance;
const ExpandableFab({
super.key,
required this.actions,
@@ -32,13 +32,13 @@ class _ExpandableFabState extends State<ExpandableFab>
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,
@@ -58,7 +58,7 @@ class _ExpandableFabState extends State<ExpandableFab>
setState(() {
_isExpanded = !_isExpanded;
});
if (_isExpanded) {
HapticFeedbackHelper.mediumImpact();
_controller.forward();
@@ -81,25 +81,26 @@ class _ExpandableFabState extends State<ExpandableFab>
animation: _expandAnimation,
builder: (context, child) {
return Container(
color: AppColors.shadowBlack.withValues(alpha: 3.75 * _expandAnimation.value),
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(
@@ -125,7 +126,7 @@ class _ExpandableFabState extends State<ExpandableFab>
},
);
}),
// 메인 FAB
AnimatedBuilder(
animation: _rotateAnimation,
@@ -144,21 +145,21 @@ class _ExpandableFabState extends State<ExpandableFab>
);
},
),
// 라벨 표시
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(
@@ -194,7 +195,7 @@ class FabAction {
final String label;
final VoidCallback onPressed;
final Color? color;
const FabAction({
required this.icon,
required this.label,
@@ -207,7 +208,7 @@ class FabAction {
class DraggableFab extends StatefulWidget {
final Widget child;
final EdgeInsets? padding;
const DraggableFab({
super.key,
required this.child,
@@ -226,7 +227,7 @@ class _DraggableFabState extends State<DraggableFab> {
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final padding = widget.padding ?? const EdgeInsets.all(20);
return Stack(
children: [
Positioned(
@@ -265,4 +266,4 @@ class _DraggableFabState extends State<DraggableFab> {
],
);
}
}
}

View File

@@ -124,8 +124,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),
),
],
),

View File

@@ -6,7 +6,8 @@ import 'themed_text.dart';
import '../l10n/app_localizations.dart';
/// 글래스모피즘 효과가 적용된 통일된 앱바
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
class GlassmorphicAppBar extends StatelessWidget
implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
@@ -44,7 +45,7 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
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),
@@ -54,17 +55,21 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
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),
(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
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
@@ -77,26 +82,29 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
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,
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!,
if (bottom != null) bottom!,
],
),
),
@@ -109,10 +117,11 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
Widget _buildBackButton(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ?? () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
onPressed: onBackPressed ??
() {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: AppLocalizations.of(context).back,
color: ThemedText.getContrastColor(context),
@@ -205,7 +214,7 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final canPop = Navigator.of(context).canPop();
return SliverAppBar(
expandedHeight: expandedHeight,
floating: floating,
@@ -214,26 +223,29 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
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),
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;
final isCollapsed =
top <= kToolbarHeight + MediaQuery.of(context).padding.top;
return FlexibleSpaceBar(
title: isCollapsed
title: isCollapsed
? ThemedText.headline(
text: title,
style: const TextStyle(
@@ -244,7 +256,8 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
)
: null,
centerTitle: centerTitle,
titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16),
titlePadding:
const EdgeInsets.only(left: 16, bottom: 16, right: 16),
background: Stack(
fit: StackFit.expand,
children: [
@@ -258,17 +271,19 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
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),
(isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground)
.withValues(alpha: opacity),
(isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface)
.withValues(alpha: opacity * 0.8),
],
),
border: Border(
bottom: BorderSide(
color: isDarkMode
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
@@ -302,4 +317,4 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
),
);
}
}
}

View File

@@ -63,12 +63,12 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
duration: const Duration(seconds: 20),
vsync: this,
)..repeat();
_waveController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
)..repeat();
if (widget.useFloatingNavBar) {
_scrollController = ScrollController();
_setupScrollListener();
@@ -78,13 +78,16 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
void _setupScrollListener() {
_scrollController?.addListener(() {
final currentScroll = _scrollController!.position.pixels;
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {
if (currentScroll > 50 &&
_scrollController!.position.userScrollDirection ==
ScrollDirection.reverse) {
if (_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = false);
}
} else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) {
} else if (_scrollController!.position.userScrollDirection ==
ScrollDirection.forward) {
if (!_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = true);
}
@@ -104,7 +107,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
if (widget.backgroundGradient != null) {
return widget.backgroundGradient!;
}
// 디폴트 그라디언트
return AppColors.mainGradient;
}
@@ -112,18 +115,18 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
@override
Widget build(BuildContext context) {
final backgroundGradient = _getBackgroundGradient();
return Stack(
children: [
// 배경 그라디언트
_buildBackground(backgroundGradient),
// 파티클 효과 (선택적)
if (widget.enableParticles) _buildParticles(),
// 웨이브 애니메이션 (선택적)
if (widget.enableWaveAnimation) _buildWaveAnimation(),
// 메인 스캐폴드
Scaffold(
backgroundColor: widget.backgroundColor ?? Colors.transparent,
@@ -138,7 +141,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
drawer: widget.drawer,
endDrawer: widget.endDrawer,
),
// 플로팅 네비게이션 바 (선택적)
if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null)
FloatingNavigationBar(
@@ -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.withOpacity(0.3))
.toList(),
),
),
),
@@ -233,11 +238,11 @@ class ParticlePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (final particle in particles) {
final progress = animation.value;
final y = (particle.y + progress * particle.speed) % 1.0;
paint.color = AppColors.pureWhite.withValues(alpha: particle.opacity);
canvas.drawCircle(
Offset(particle.x * size.width, y * size.height),
@@ -266,21 +271,23 @@ class WavePainter extends CustomPainter {
final paint = Paint()
..color = waveColor
..style = PaintingStyle.fill;
final path = Path();
final progress = animation.value;
path.moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) {
final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 +
size.height * 0.5;
final y =
math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) *
20 +
size.height * 0.5;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@@ -303,4 +310,4 @@ class Particle {
required this.speed,
required this.opacity,
});
}
}

View File

@@ -38,7 +38,7 @@ class GlassmorphismCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
width: width,
height: height,
@@ -56,28 +56,32 @@ class GlassmorphismCard extends StatelessWidget {
padding: padding,
decoration: BoxDecoration(
color: backgroundColor ?? AppColors.glassCard,
gradient: gradient ?? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? AppColors.glassGradientDark
: AppColors.glassGradient,
),
gradient: gradient ??
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? AppColors.glassGradientDark
: AppColors.glassGradient,
),
borderRadius: BorderRadius.circular(borderRadius),
border: border ?? Border.all(
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder,
width: 1,
),
boxShadow: boxShadow ?? [
BoxShadow(
color: AppColors.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20,
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
border: border ??
Border.all(
color: isDarkMode
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder,
width: 1,
),
boxShadow: boxShadow ??
[
BoxShadow(
color: AppColors
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20,
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
),
child: GlassmorphicIndicator(
child: child,
@@ -119,10 +123,11 @@ class AnimatedGlassmorphismCard extends StatefulWidget {
});
@override
State<AnimatedGlassmorphismCard> createState() => _AnimatedGlassmorphismCardState();
State<AnimatedGlassmorphismCard> createState() =>
_AnimatedGlassmorphismCardState();
}
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@@ -135,7 +140,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
@@ -143,7 +148,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
parent: _controller,
curve: Curves.easeInOut,
));
_blurAnimation = Tween<double>(
begin: widget.blur,
end: widget.blur * 1.5,
@@ -187,7 +192,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
child: widget.child,
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown,
@@ -221,4 +226,4 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
),
);
}
}
}

View File

@@ -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,
@@ -107,8 +109,8 @@ class HomeContent extends StatelessWidget {
child: Text(
AppLocalizations.of(context).mySubscriptions,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.darkNavy,
),
color: AppColors.darkNavy,
),
),
),
SlideTransition(
@@ -120,7 +122,8 @@ class HomeContent extends StatelessWidget {
child: Row(
children: [
Text(
AppLocalizations.of(context).subscriptionCount(provider.subscriptions.length),
AppLocalizations.of(context)
.subscriptionCount(provider.subscriptions.length),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -153,4 +156,4 @@ class HomeContent extends StatelessWidget {
),
);
}
}
}

View File

@@ -33,7 +33,7 @@ class MainScreenSummaryCard extends StatelessWidget {
final locale = context.watch<LocaleProvider>().locale.languageCode;
final defaultCurrency = CurrencyUtil.getDefaultCurrency(locale);
final currencySymbol = CurrencyUtil.getCurrencySymbol(defaultCurrency);
final int totalSubscriptions = provider.subscriptions.length;
final int activeEvents = provider.activeEventSubscriptions.length;
@@ -88,7 +88,8 @@ class MainScreenSummaryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context).monthlyTotalSubscriptionCost,
AppLocalizations.of(context)
.monthlyTotalSubscriptionCost,
style: TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
@@ -99,9 +100,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,
@@ -116,7 +120,9 @@ class MainScreenSummaryCard extends StatelessWidget {
),
),
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 +139,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 8),
// 월별 총 비용 표시 (언어별 기본 통화)
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions,
locale,
),
@@ -142,17 +149,24 @@ 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,
textBaseline: TextBaseline.alphabetic,
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 +193,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 16),
// 연간 비용 및 총 구독 수 표시
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalMonthlyExpenseInDefaultCurrency(
future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency(
provider.subscriptions,
locale,
),
@@ -189,17 +204,25 @@ 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,
title: AppLocalizations.of(context)
.estimatedAnnualCost,
value: '${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(yearlyCost)}',
@@ -207,8 +230,10 @@ class MainScreenSummaryCard extends StatelessWidget {
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}',
),
],
);
@@ -255,7 +280,8 @@ class MainScreenSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).eventDiscountActive,
AppLocalizations.of(context)
.eventDiscountActive,
style: TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
@@ -266,7 +292,8 @@ class MainScreenSummaryCard extends StatelessWidget {
const SizedBox(height: 2),
// 이벤트 절약액 표시 (언어별 기본 통화)
FutureBuilder<double>(
future: CurrencyUtil.calculateTotalEventSavingsInDefaultCurrency(
future: CurrencyUtil
.calculateTotalEventSavingsInDefaultCurrency(
provider.subscriptions,
locale,
),
@@ -275,15 +302,24 @@ 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),

View File

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

View File

@@ -5,7 +5,7 @@ class SkeletonLoading extends StatelessWidget {
final double? width;
final double? height;
final double borderRadius;
const SkeletonLoading({
Key? key,
this.width,
@@ -19,7 +19,7 @@ class SkeletonLoading extends StatelessWidget {
if (width != null || height != null) {
return _buildSingleSkeleton();
}
// 기본 전체 화면 스켈레톤
return Column(
children: [
@@ -30,25 +30,25 @@ class SkeletonLoading extends StatelessWidget {
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),
),
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(),
],
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSkeletonColumn(),
_buildSkeletonColumn(),
],
),
],
),
),
// 구독 목록 스켈레톤
@@ -156,4 +156,4 @@ class SkeletonLoading extends StatelessWidget {
],
);
}
}
}

View File

@@ -67,4 +67,4 @@ class ScanInitialWidget extends StatelessWidget {
],
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More