19 Commits

Author SHA1 Message Date
JiWoong Sul
37e797f6c1 chore: 버전 1.0.8+10 업데이트 2026-01-30 15:33:08 +09:00
JiWoong Sul
903906c880 fix(billing): 월별 비용 계산 시 연도 무시 버그 수정
- hasBillingInMonth()에서 targetYear를 실제로 사용하도록 수정
- 연간 구독 수정 시 잘못된 월에 비용이 포함되던 문제 해결
- 연도+월을 포함한 개월 차이 계산으로 정확한 결제 발생 여부 판단
2026-01-29 23:55:57 +09:00
JiWoong Sul
5de33992a2 fix(sms-scan): 에러 메시지를 토스트로 변경
- 구독 정보를 찾지 못한 경우 상단 붉은색 텍스트 대신 하단 토스트 메시지 출력
- ScanInitialWidget에서 errorMessage 파라미터 제거
- SmsScanController에서 AppSnackBar.showError() 사용
- 불필요한 _errorMessage 상태 변수 제거
2026-01-29 23:55:49 +09:00
JiWoong Sul
cc8bcc7b54 refactor(home): 구독 개수 표시에서 화살표 아이콘 제거
- 메인 화면의 구독 개수 표시 우측 ">" 아이콘 삭제
- 불필요한 UI 요소 정리
2026-01-29 23:55:39 +09:00
JiWoong Sul
9a950ee6c7 chore: 버전 1.0.7+9 업데이트 2026-01-21 17:01:32 +09:00
JiWoong Sul
88569a57bf chore: 스플래시 화면 저작권 텍스트에 cclabs 추가 2026-01-21 17:01:22 +09:00
JiWoong Sul
7125a4745a feat(settings): 앱 버전 자동 표시 기능 추가
- package_info_plus 패키지 추가
- settings_screen에서 pubspec.yaml 버전을 자동으로 표시
2026-01-17 00:31:41 +09:00
JiWoong Sul
8d6b24ed6f chore: 버전 1.0.6+8 업데이트 2026-01-17 00:15:52 +09:00
JiWoong Sul
0db1f12b40 feat: Android 15 edge-to-edge 모드 지원
- immersiveSticky → edgeToEdge 모드 변경
- deprecated된 네비게이션바 색상 API 제거
- 시스템이 네비게이션바 색상 자동 처리
2026-01-14 19:12:35 +09:00
JiWoong Sul
595513b2e6 refactor: MainActivity 불필요한 주석 제거 2026-01-14 19:12:28 +09:00
JiWoong Sul
98488dbcd5 chore: 버전 1.0.5+7 업데이트 2026-01-14 00:18:43 +09:00
JiWoong Sul
18a0004d57 feat(ui): 결제 금액 UI 표시 적용 2026-01-14 00:18:37 +09:00
JiWoong Sul
6e7a7d2477 feat: 컨트롤러에 결제 금액 표시 로직 추가 2026-01-14 00:18:30 +09:00
JiWoong Sul
a0b24f9a75 feat: SubscriptionProvider 결제 금액 계산 로직 추가 2026-01-14 00:18:25 +09:00
JiWoong Sul
58c00443c1 feat(i18n): 결제 금액 다국어 키 추가 2026-01-14 00:18:19 +09:00
JiWoong Sul
da530a99b7 feat: 결제 금액 계산 유틸리티 추가 2026-01-14 00:18:12 +09:00
JiWoong Sul
0f92206833 chore: 버전 1.0.3+5 업데이트 2026-01-06 15:53:51 +09:00
JiWoong Sul
db93c14105 fix: 광고 후 UI 복구 시 몰입형 모드 유지 2026-01-06 15:53:45 +09:00
JiWoong Sul
c8c4746f52 feat: 시스템 네비게이션 바 몰입형 모드 적용 2026-01-06 15:53:38 +09:00
25 changed files with 650 additions and 185 deletions

View File

@@ -2,7 +2,4 @@ package com.naturebridgeai.digitalrentmanager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity()
// flutter_sms_inbox 패키지가 SMS 처리를 담당하므로
// 기존 MethodChannel 코드는 제거되었습니다
}

View File

@@ -99,6 +99,7 @@
"changesAppliedAfterSave": "Changes will be applied after saving", "changesAppliedAfterSave": "Changes will be applied after saving",
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"monthlyExpense": "Monthly Expense", "monthlyExpense": "Monthly Expense",
"billingAmount": "Billing Amount",
"websiteUrl": "Website URL", "websiteUrl": "Website URL",
"websiteUrlOptional": "Website URL (Optional)", "websiteUrlOptional": "Website URL (Optional)",
"eventPrice": "Event Price", "eventPrice": "Event Price",
@@ -372,6 +373,7 @@
"changesAppliedAfterSave": "변경사항은 저장 후 적용됩니다", "changesAppliedAfterSave": "변경사항은 저장 후 적용됩니다",
"saveChanges": "변경사항 저장", "saveChanges": "변경사항 저장",
"monthlyExpense": "월 지출", "monthlyExpense": "월 지출",
"billingAmount": "결제 금액",
"websiteUrl": "웹사이트 URL", "websiteUrl": "웹사이트 URL",
"websiteUrlOptional": "웹사이트 URL (선택)", "websiteUrlOptional": "웹사이트 URL (선택)",
"eventPrice": "이벤트 가격", "eventPrice": "이벤트 가격",
@@ -645,6 +647,7 @@
"changesAppliedAfterSave": "変更は保存後に適用されます", "changesAppliedAfterSave": "変更は保存後に適用されます",
"saveChanges": "変更を保存", "saveChanges": "変更を保存",
"monthlyExpense": "月額支出", "monthlyExpense": "月額支出",
"billingAmount": "請求金額",
"websiteUrl": "ウェブサイトURL", "websiteUrl": "ウェブサイトURL",
"websiteUrlOptional": "ウェブサイトURLオプション", "websiteUrlOptional": "ウェブサイトURLオプション",
"eventPrice": "イベント価格", "eventPrice": "イベント価格",
@@ -908,6 +911,7 @@
"changesAppliedAfterSave": "更改将在保存后应用", "changesAppliedAfterSave": "更改将在保存后应用",
"saveChanges": "保存更改", "saveChanges": "保存更改",
"monthlyExpense": "每月支出", "monthlyExpense": "每月支出",
"billingAmount": "账单金额",
"websiteUrl": "网站URL", "websiteUrl": "网站URL",
"websiteUrlOptional": "网站URL可选", "websiteUrlOptional": "网站URL可选",
"eventPrice": "活动价格", "eventPrice": "活动价格",

View File

@@ -10,6 +10,7 @@ import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart'; import '../utils/billing_date_util.dart';
import '../utils/billing_cost_util.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
@@ -485,14 +486,22 @@ class AddSubscriptionController {
try { try {
// 콤마 제거하고 숫자만 추출 // 콤마 제거하고 숫자만 추출
final monthlyCost = final inputCost =
double.parse(monthlyCostController.text.replaceAll(',', '')); double.parse(monthlyCostController.text.replaceAll(',', ''));
// 이벤트 가격 파싱 // 결제 주기에 따라 월 비용으로 변환
final monthlyCost =
BillingCostUtil.convertToMonthlyCost(inputCost, billingCycle);
// 이벤트 가격 파싱 및 월 비용 변환
double? eventPrice; double? eventPrice;
if (isEventActive && eventPriceController.text.isNotEmpty) { if (isEventActive && eventPriceController.text.isNotEmpty) {
eventPrice = final inputEventPrice =
double.tryParse(eventPriceController.text.replaceAll(',', '')); double.tryParse(eventPriceController.text.replaceAll(',', ''));
if (inputEventPrice != null) {
eventPrice =
BillingCostUtil.convertToMonthlyCost(inputEventPrice, billingCycle);
}
} }
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월 // 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월

View File

@@ -13,6 +13,7 @@ import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/billing_date_util.dart'; import '../utils/billing_date_util.dart';
import '../utils/billing_cost_util.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController extends ChangeNotifier { class DetailScreenController extends ChangeNotifier {
@@ -58,6 +59,8 @@ class DetailScreenController extends ChangeNotifier {
set billingCycle(String value) { set billingCycle(String value) {
if (_billingCycle != value) { if (_billingCycle != value) {
_billingCycle = value; _billingCycle = value;
// 결제 주기 변경 시 금액 표시 업데이트
_updateMonthlyCostFormat();
notifyListeners(); notifyListeners();
} }
} }
@@ -170,14 +173,18 @@ class DetailScreenController extends ChangeNotifier {
_eventStartDate = subscription.eventStartDate; _eventStartDate = subscription.eventStartDate;
_eventEndDate = subscription.eventEndDate; _eventEndDate = subscription.eventEndDate;
// 이벤트 가격 초기화 // 이벤트 가격 초기화 (월 비용을 결제 주기별 실제 금액으로 변환)
if (subscription.eventPrice != null) { if (subscription.eventPrice != null) {
final actualEventPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.eventPrice!,
_billingCycle,
);
if (currency == 'KRW') { if (currency == 'KRW') {
eventPriceController.text = NumberFormat.decimalPattern() eventPriceController.text = NumberFormat.decimalPattern()
.format(subscription.eventPrice!.toInt()); .format(actualEventPrice.toInt());
} else { } else {
eventPriceController.text = eventPriceController.text =
NumberFormat('#,##0.00').format(subscription.eventPrice!); NumberFormat('#,##0.00').format(actualEventPrice);
} }
} }
@@ -271,16 +278,23 @@ class DetailScreenController extends ChangeNotifier {
} }
/// 통화 단위에 따른 금액 표시 형식 업데이트 /// 통화 단위에 따른 금액 표시 형식 업데이트
/// 월 비용을 결제 주기에 맞는 실제 금액으로 변환하여 표시
void _updateMonthlyCostFormat() { void _updateMonthlyCostFormat() {
// 월 비용을 결제 주기별 실제 금액으로 변환
final actualCost = BillingCostUtil.convertFromMonthlyCost(
subscription.monthlyCost,
_billingCycle,
);
if (_currency == 'KRW') { if (_currency == 'KRW') {
// 원화는 소수점 없이 표시 // 원화는 소수점 없이 표시
final intValue = subscription.monthlyCost.toInt(); final intValue = actualCost.toInt();
monthlyCostController.text = monthlyCostController.text =
NumberFormat.decimalPattern().format(intValue); NumberFormat.decimalPattern().format(intValue);
} else { } else {
// 달러는 소수점 2자리까지 표시 // 달러는 소수점 2자리까지 표시
monthlyCostController.text = monthlyCostController.text =
NumberFormat('#,##0.00').format(subscription.monthlyCost); NumberFormat('#,##0.00').format(actualCost);
} }
} }
@@ -400,11 +414,14 @@ class DetailScreenController extends ChangeNotifier {
// 구독 정보 업데이트 // 구독 정보 업데이트
// 콤마 제거하고 숫자만 추출 // 콤마 제거하고 숫자만 추출 후 월 비용으로 변환
double monthlyCost = 0.0; double monthlyCost = 0.0;
try { try {
monthlyCost = final inputCost =
double.parse(monthlyCostController.text.replaceAll(',', '')); double.parse(monthlyCostController.text.replaceAll(',', ''));
// 결제 주기에 따라 월 비용으로 변환
monthlyCost =
BillingCostUtil.convertToMonthlyCost(inputCost, _billingCycle);
} catch (e) { } catch (e) {
// 파싱 오류 발생 시 기본값 사용 // 파싱 오류 발생 시 기본값 사용
monthlyCost = subscription.monthlyCost; monthlyCost = subscription.monthlyCost;
@@ -412,7 +429,7 @@ class DetailScreenController extends ChangeNotifier {
debugPrint('[DetailScreenController] 구독 업데이트 시작: ' debugPrint('[DetailScreenController] 구독 업데이트 시작: '
'${subscription.serviceName}${serviceNameController.text}, ' '${subscription.serviceName}${serviceNameController.text}, '
'금액: $subscription.monthlyCost → $monthlyCost $_currency'); '금액: ${subscription.monthlyCost}$monthlyCost $_currency');
subscription.serviceName = serviceNameController.text; subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
@@ -433,11 +450,13 @@ class DetailScreenController extends ChangeNotifier {
subscription.eventStartDate = _eventStartDate; subscription.eventStartDate = _eventStartDate;
subscription.eventEndDate = _eventEndDate; subscription.eventEndDate = _eventEndDate;
// 이벤트 가격 파싱 // 이벤트 가격 파싱 및 월 비용 변환
if (_isEventActive && eventPriceController.text.isNotEmpty) { if (_isEventActive && eventPriceController.text.isNotEmpty) {
try { try {
subscription.eventPrice = final inputEventPrice =
double.parse(eventPriceController.text.replaceAll(',', '')); double.parse(eventPriceController.text.replaceAll(',', ''));
subscription.eventPrice =
BillingCostUtil.convertToMonthlyCost(inputEventPrice, _billingCycle);
} catch (e) { } catch (e) {
subscription.eventPrice = null; subscription.eventPrice = null;
} }

View File

@@ -17,15 +17,13 @@ import '../providers/category_provider.dart';
import '../providers/payment_card_provider.dart'; import '../providers/payment_card_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
class SmsScanController extends ChangeNotifier { class SmsScanController extends ChangeNotifier {
// 상태 관리 // 상태 관리
bool _isLoading = false; bool _isLoading = false;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
List<Subscription> _scannedSubscriptions = []; List<Subscription> _scannedSubscriptions = [];
List<Subscription> get scannedSubscriptions => _scannedSubscriptions; List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
PaymentCardSuggestion? _currentSuggestion; PaymentCardSuggestion? _currentSuggestion;
@@ -109,7 +107,6 @@ class SmsScanController extends ChangeNotifier {
Future<void> scanSms(BuildContext context) async { Future<void> scanSms(BuildContext context) async {
_isLoading = true; _isLoading = true;
_errorMessage = null;
_scannedSubscriptions = []; _scannedSubscriptions = [];
_currentIndex = 0; _currentIndex = 0;
notifyListeners(); notifyListeners();
@@ -137,9 +134,12 @@ class SmsScanController extends ChangeNotifier {
final req = await permission.Permission.sms.request(); final req = await permission.Permission.sms.request();
if (!ctx.mounted) return; if (!ctx.mounted) return;
if (!req.isGranted) { if (!req.isGranted) {
// 거부됨: 안내 후 종료 // 거부됨: 토스트 표시 후 종료
if (!ctx.mounted) return; if (!ctx.mounted) return;
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired; AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx).smsPermissionRequired,
);
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return; return;
@@ -162,7 +162,10 @@ class SmsScanController extends ChangeNotifier {
if (scanResults.isEmpty) { if (scanResults.isEmpty) {
Log.i('스캔된 구독이 없음'); Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound; AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).subscriptionNotFound,
);
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return; return;
@@ -184,7 +187,10 @@ class SmsScanController extends ChangeNotifier {
if (repeatSubscriptions.isEmpty) { if (repeatSubscriptions.isEmpty) {
Log.i('반복 결제된 구독이 없음'); Log.i('반복 결제된 구독이 없음');
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context).repeatSubscriptionNotFound,
);
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
return; return;
@@ -223,8 +229,10 @@ class SmsScanController extends ChangeNotifier {
} catch (e) { } catch (e) {
Log.e('SMS 스캔 중 오류 발생', e); Log.e('SMS 스캔 중 오류 발생', e);
if (context.mounted) { if (context.mounted) {
_errorMessage = AppSnackBar.showError(
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); context: context,
message: AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()),
);
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
} }
@@ -343,7 +351,6 @@ class SmsScanController extends ChangeNotifier {
void resetState() { void resetState() {
_scannedSubscriptions = []; _scannedSubscriptions = [];
_currentIndex = 0; _currentIndex = 0;
_errorMessage = null;
_selectedPaymentCardId = null; _selectedPaymentCardId = null;
_currentSuggestion = null; _currentSuggestion = null;
_shouldSuggestCardCreation = false; _shouldSuggestCardCreation = false;

View File

@@ -231,6 +231,8 @@ class AppLocalizations {
String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes'; String get saveChanges => _localizedStrings['saveChanges'] ?? 'Save Changes';
String get monthlyExpense => String get monthlyExpense =>
_localizedStrings['monthlyExpense'] ?? 'Monthly Expense'; _localizedStrings['monthlyExpense'] ?? 'Monthly Expense';
String get billingAmount =>
_localizedStrings['billingAmount'] ?? 'Billing Amount';
String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL'; String get websiteUrl => _localizedStrings['websiteUrl'] ?? 'Website URL';
String get websiteUrlOptional => String get websiteUrlOptional =>
_localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)'; _localizedStrings['websiteUrlOptional'] ?? 'Website URL (Optional)';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
@@ -34,6 +35,10 @@ const bool enableAdMob = true;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Android 15 edge-to-edge 모드 활성화
// 콘텐츠가 시스템 바 영역까지 확장됨
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만) // 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && enableAdMob) {
unawaited(MobileAds.instance.initialize()); unawaited(MobileAds.instance.initialize());

View File

@@ -10,6 +10,7 @@ import '../services/currency_util.dart';
import 'category_provider.dart'; import 'category_provider.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../navigator_key.dart'; import '../navigator_key.dart';
import '../utils/billing_cost_util.dart';
class SubscriptionProvider extends ChangeNotifier { class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox; late Box<SubscriptionModel> _subscriptionBox;
@@ -24,18 +25,40 @@ class SubscriptionProvider extends ChangeNotifier {
final rate = exchangeRateService.cachedUsdToKrwRate ?? final rate = exchangeRateService.cachedUsdToKrwRate ??
ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; ExchangeRateService.DEFAULT_USD_TO_KRW_RATE;
final now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
final total = _subscriptions.fold( final total = _subscriptions.fold(
0.0, 0.0,
(sum, subscription) { (sum, subscription) {
final price = subscription.currentPrice; // 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'이번 달 결제 없음, 제외');
return sum;
}
// 실제 결제 금액으로 역변환
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
if (subscription.currency == 'USD') { if (subscription.currency == 'USD') {
debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' debugPrint('[SubscriptionProvider] ${subscription.serviceName}: '
'\$$price ×$rate = ₩${price * rate}'); '\$$actualPrice ×$rate = ₩${actualPrice * rate}');
return sum + (price * rate); return sum + (actualPrice * rate);
} }
debugPrint( debugPrint(
'[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); '[SubscriptionProvider] ${subscription.serviceName}: ₩$actualPrice');
return sum + price; return sum + actualPrice;
}, },
); );
@@ -76,6 +99,9 @@ class SubscriptionProvider extends ChangeNotifier {
// categoryId 마이그레이션 // categoryId 마이그레이션
await _migrateCategoryIds(); await _migrateCategoryIds();
// billingCycle별 비용 마이그레이션 (연간/분기별 구독 월 비용 변환)
await _migrateBillingCosts();
// 앱 시작 시 이벤트 상태 확인 // 앱 시작 시 이벤트 상태 확인
await checkAndUpdateEventStatus(); await checkAndUpdateEventStatus();
@@ -274,7 +300,9 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
/// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) /// 이번 달 총 지출을 계산합니다. (로케일별 기본 통화로 환산)
/// - 이번 달에 결제가 발생하는 구독만 포함
/// - 실제 결제 금액으로 계산 (연간이면 연간 금액)
Future<double> calculateTotalExpense({ Future<double> calculateTotalExpense({
String? locale, String? locale,
List<SubscriptionModel>? subset, List<SubscriptionModel>? subset,
@@ -282,26 +310,50 @@ class SubscriptionProvider extends ChangeNotifier {
final targetSubscriptions = subset ?? _subscriptions; final targetSubscriptions = subset ?? _subscriptions;
if (targetSubscriptions.isEmpty) return 0.0; if (targetSubscriptions.isEmpty) return 0.0;
final now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
// locale이 제공되지 않으면 현재 로케일 사용 // locale이 제공되지 않으면 현재 로케일 사용
final targetCurrency = final targetCurrency =
locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값
debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, ' debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, '
'대상 구독: ${targetSubscriptions.length}'); '대상 구독: ${targetSubscriptions.length}, 현재 월: $currentYear-$currentMonth');
double total = 0.0; double total = 0.0;
for (final subscription in targetSubscriptions) { for (final subscription in targetSubscriptions) {
final currentPrice = subscription.currentPrice; // 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) {
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'이번 달 결제 없음 - 제외');
continue;
}
// 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액)
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' debugPrint('[calculateTotalExpense] ${subscription.serviceName}: '
'$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '실제 결제 금액 $actualPrice ${subscription.currency} '
'(월 비용: ${subscription.currentPrice}, 주기: ${subscription.billingCycle})');
final converted = await ExchangeRateService().convertBetweenCurrencies( final converted = await ExchangeRateService().convertBetweenCurrencies(
currentPrice, actualPrice,
subscription.currency, subscription.currency,
targetCurrency, targetCurrency,
); );
total += converted ?? currentPrice; total += converted ?? actualPrice;
} }
debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total ' debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total '
@@ -310,6 +362,8 @@ class SubscriptionProvider extends ChangeNotifier {
} }
/// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산)
/// - 각 월에 결제가 발생하는 구독만 포함
/// - 실제 결제 금액으로 계산 (연간이면 연간 금액)
Future<List<Map<String, dynamic>>> getMonthlyExpenseData({ Future<List<Map<String, dynamic>>> getMonthlyExpenseData({
String? locale, String? locale,
List<SubscriptionModel>? subset, List<SubscriptionModel>? subset,
@@ -336,60 +390,63 @@ class SubscriptionProvider extends ChangeNotifier {
'[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); '[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...');
} }
// 해당 월에 활성화된 구독 계산 // 해당 월에 결제가 발생하는 구독 계산
for (final subscription in targetSubscriptions) { for (final subscription in targetSubscriptions) {
// 해당 월에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
month.year,
month.month,
);
if (!hasBilling) {
continue; // 해당 월에 결제가 없으면 제외
}
// 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액)
double actualCost;
if (isCurrentMonth) {
// 현재 월: 이벤트 가격 반영
actualCost = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
} else {
// 과거 월: 이벤트 기간 확인 후 적용
double monthlyCost;
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
subscription.eventStartDate!
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.eventEndDate!.isAfter(month)) {
monthlyCost = subscription.eventPrice ?? subscription.monthlyCost;
} else {
monthlyCost = subscription.monthlyCost;
}
actualCost = BillingCostUtil.convertFromMonthlyCost(
monthlyCost,
subscription.billingCycle,
);
}
if (isCurrentMonth) { if (isCurrentMonth) {
// 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게)
final cost = subscription.currentPrice;
debugPrint( debugPrint(
'[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: '
'$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); '실제 결제 금액 $actualCost ${subscription.currency}');
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
} else {
// 과거 월인 경우: 기존 로직 유지
// 구독이 해당 월에 활성화되어 있었는지 확인
final subscriptionStartDate = subscription.nextBillingDate.subtract(
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
);
if (subscriptionStartDate
.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려)
double cost;
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
// 이벤트 기간과 해당 월이 겹치는지 확인
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(
cost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? cost;
}
} }
// 통화 변환
final converted =
await ExchangeRateService().convertBetweenCurrencies(
actualCost,
subscription.currency,
targetCurrency,
);
monthTotal += converted ?? actualCost;
} }
if (isCurrentMonth) { if (isCurrentMonth) {
@@ -413,22 +470,6 @@ class SubscriptionProvider extends ChangeNotifier {
return totalEventSavings; return totalEventSavings;
} }
/// 결제 주기를 일 단위로 변환합니다.
int _getBillingCycleDays(String billingCycle) {
switch (billingCycle) {
case 'monthly':
return 30;
case 'yearly':
return 365;
case 'weekly':
return 7;
case 'quarterly':
return 90;
default:
return 30;
}
}
/// 월 라벨을 생성합니다. /// 월 라벨을 생성합니다.
String _getMonthLabel(DateTime month, String locale) { String _getMonthLabel(DateTime month, String locale) {
if (locale == 'ko') { if (locale == 'ko') {
@@ -557,4 +598,59 @@ class SubscriptionProvider extends ChangeNotifier {
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
} }
} }
/// billingCycle별 비용 마이그레이션
/// 기존 연간/분기별 구독의 monthlyCost를 월 환산 비용으로 변환
Future<void> _migrateBillingCosts() async {
debugPrint('💰 BillingCost 마이그레이션 시작...');
int migratedCount = 0;
for (var subscription in _subscriptions) {
final cycle = subscription.billingCycle.toLowerCase();
// 월간 구독이 아닌 경우에만 변환 필요
if (cycle != 'monthly' && cycle != '월간' && cycle != '매월') {
// 현재 monthlyCost가 실제 월 비용인지 확인
// 연간 구독인데 monthlyCost가 12배 이상 크면 변환 안됨 상태로 판단
final multiplier = BillingCostUtil.getBillingCycleMultiplier(cycle);
// 변환이 필요한 경우: monthlyCost가 비정상적으로 큰 경우
// (예: 연간 129,000원이 monthlyCost에 그대로 저장된 경우)
if (multiplier > 1.5) {
// 원래 monthlyCost를 백업
final originalCost = subscription.monthlyCost;
// 월 비용으로 변환
final convertedCost = BillingCostUtil.convertToMonthlyCost(
originalCost,
cycle,
);
// 이벤트 가격도 있다면 변환
if (subscription.eventPrice != null) {
final convertedEventPrice = BillingCostUtil.convertToMonthlyCost(
subscription.eventPrice!,
cycle,
);
subscription.eventPrice = convertedEventPrice;
}
subscription.monthlyCost = convertedCost;
await subscription.save();
migratedCount++;
debugPrint('${subscription.serviceName} ($cycle): '
'${originalCost.toInt()} → ₩${convertedCost.toInt()}/월');
}
}
}
if (migratedCount > 0) {
debugPrint('💰 총 $migratedCount개의 구독 비용 변환 완료');
await refreshSubscriptions();
} else {
debugPrint('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다');
}
}
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../providers/notification_provider.dart'; import '../providers/notification_provider.dart';
import 'dart:io'; import 'dart:io';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
@@ -884,22 +885,28 @@ class SettingsScreen extends StatelessWidget {
.withValues(alpha: 0.5), .withValues(alpha: 0.5),
), ),
), ),
child: ListTile( child: FutureBuilder<PackageInfo>(
contentPadding: const EdgeInsets.all(8), future: PackageInfo.fromPlatform(),
title: Text( builder: (context, snapshot) {
AppLocalizations.of(context).appInfo, final version = snapshot.data?.version ?? '-';
style: TextStyle( return ListTile(
color: Theme.of(context).colorScheme.onSurface), contentPadding: const EdgeInsets.all(8),
), title: Text(
subtitle: Text( AppLocalizations.of(context).appInfo,
'${AppLocalizations.of(context).version} 1.0.0', style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onSurface),
color: ),
Theme.of(context).colorScheme.onSurfaceVariant), subtitle: Text(
), '${AppLocalizations.of(context).version} $version',
leading: Icon(Icons.info, style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant), color: Theme.of(context)
onTap: null, .colorScheme
.onSurfaceVariant),
),
leading: Icon(Icons.info,
color:
Theme.of(context).colorScheme.onSurfaceVariant),
onTap: null,
// onTap: () async { // onTap: () async {
// // 항상 앱 내 About 다이얼로그를 우선 표시 (현재 미사용) // // 항상 앱 내 About 다이얼로그를 우선 표시 (현재 미사용)
// showAboutDialog( // showAboutDialog(
@@ -954,6 +961,8 @@ class SettingsScreen extends StatelessWidget {
// ], // ],
// ); // );
// }, // },
);
},
), ),
), ),
// FloatingNavigationBar를 위한 충분한 하단 여백 // FloatingNavigationBar를 위한 충분한 하단 여백

View File

@@ -58,7 +58,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (_controller.scannedSubscriptions.isEmpty) { if (_controller.scannedSubscriptions.isEmpty) {
return ScanInitialWidget( return ScanInitialWidget(
onScanPressed: () => _controller.startScan(context), onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage,
); );
} }
@@ -77,7 +76,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
}); });
return ScanInitialWidget( return ScanInitialWidget(
onScanPressed: () => _controller.startScan(context), onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage,
); );
} }

View File

@@ -384,7 +384,7 @@ class _SplashScreenState extends State<SplashScreen>
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: Text( child: Text(
'© 2025 NatureBridgeAI. All rights reserved.', '© 2025 NatureBridgeAI @ cclabs. All rights reserved.',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Theme.of(context) color: Theme.of(context)

View File

@@ -146,10 +146,13 @@ class AdService {
} catch (_) {} } catch (_) {}
} }
/// UI 복구 /// UI 복구 (main.dart의 설정과 동일하게 immersiveSticky 유지)
Future<void> _restoreSystemUi() async { Future<void> _restoreSystemUi() async {
try { try {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [SystemUiOverlay.top], // 상태바만 유지
);
} catch (_) {} } catch (_) {}
} }

View File

@@ -1,5 +1,6 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/billing_cost_util.dart';
import 'exchange_rate_service.dart'; import 'exchange_rate_service.dart';
import 'cache_manager.dart'; import 'cache_manager.dart';
@@ -129,7 +130,8 @@ class CurrencyUtil {
return result; return result;
} }
/// 구독 목록의 총 비용을 계산 (언어별 기본 통화로) /// 구독 목록의 이번 달 총 비용을 계산 (언어별 기본 통화로)
/// 이번 달에 결제가 발생하는 구독만 포함하며, 실제 결제 금액을 사용
static Future<double> calculateTotalMonthlyExpenseInDefaultCurrency( static Future<double> calculateTotalMonthlyExpenseInDefaultCurrency(
List<SubscriptionModel> subscriptions, List<SubscriptionModel> subscriptions,
String locale, String locale,
@@ -137,16 +139,33 @@ class CurrencyUtil {
final defaultCurrency = getDefaultCurrency(locale); final defaultCurrency = getDefaultCurrency(locale);
double total = 0.0; double total = 0.0;
final now = DateTime.now();
final currentYear = now.year;
final currentMonth = now.month;
for (var subscription in subscriptions) { for (var subscription in subscriptions) {
final price = subscription.currentPrice; // 이번 달에 결제가 발생하는지 확인
final hasBilling = BillingCostUtil.hasBillingInMonth(
subscription.nextBillingDate,
subscription.billingCycle,
currentYear,
currentMonth,
);
if (!hasBilling) continue;
// 실제 결제 금액으로 역변환
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
final converted = await _exchangeRateService.convertBetweenCurrencies( final converted = await _exchangeRateService.convertBetweenCurrencies(
price, actualPrice,
subscription.currency, subscription.currency,
defaultCurrency, defaultCurrency,
); );
total += converted ?? price; total += converted ?? actualPrice;
} }
return total; return total;
@@ -158,17 +177,46 @@ class CurrencyUtil {
return calculateTotalMonthlyExpenseInDefaultCurrency(subscriptions, 'ko'); return calculateTotalMonthlyExpenseInDefaultCurrency(subscriptions, 'ko');
} }
/// 구독 목록의 예상 연간 총 비용을 계산 (언어별 기본 통화로)
/// 모든 구독의 연간 비용을 합산 (월 환산 비용 × 12)
static Future<double> calculateTotalAnnualExpenseInDefaultCurrency(
List<SubscriptionModel> subscriptions,
String locale,
) async {
final defaultCurrency = getDefaultCurrency(locale);
double total = 0.0;
for (var subscription in subscriptions) {
// 월 환산 비용 × 12 = 연간 비용
final annualPrice = subscription.currentPrice * 12;
final converted = await _exchangeRateService.convertBetweenCurrencies(
annualPrice,
subscription.currency,
defaultCurrency,
);
total += converted ?? annualPrice;
}
return total;
}
/// 구독의 월 비용을 표시 형식에 맞게 변환 (언어별 통화) /// 구독의 월 비용을 표시 형식에 맞게 변환 (언어별 통화)
static Future<String> formatSubscriptionAmountWithLocale( static Future<String> formatSubscriptionAmountWithLocale(
SubscriptionModel subscription, String locale) async { SubscriptionModel subscription, String locale) async {
final price = subscription.currentPrice; // 월 환산 금액을 실제 결제 금액으로 역변환
// 구독 단위 캐시 키 (통화/가격/locale + id) final price = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
// 구독 단위 캐시 키 (통화/가격/locale + id + billingCycle)
final decimals = final decimals =
(subscription.currency == 'KRW' || subscription.currency == 'JPY') (subscription.currency == 'KRW' || subscription.currency == 'JPY')
? 0 ? 0
: 2; : 2;
final key = final key =
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}'; 'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}:${subscription.billingCycle}';
final cached = _fmtCache.get(key); final cached = _fmtCache.get(key);
if (cached != null) return cached; if (cached != null) return cached;

View File

@@ -305,9 +305,9 @@ class AdaptiveTheme {
} }
/// 시스템 테마에 따른 상태바 스타일 적용 /// 시스템 테마에 따른 상태바 스타일 적용
/// Android 15+ edge-to-edge 호환: deprecated된 네비게이션바 색상 API 제거
static void applySystemUIOverlay(BuildContext context) { static void applySystemUIOverlay(BuildContext context) {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
@@ -315,13 +315,8 @@ class AdaptiveTheme {
brightness == Brightness.dark ? Brightness.light : Brightness.dark, brightness == Brightness.dark ? Brightness.light : Brightness.dark,
statusBarBrightness: statusBarBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark, brightness == Brightness.dark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isOled // Android 15+: 네비게이션바 색상은 시스템이 자동 처리
? Colors.black systemNavigationBarContrastEnforced: false,
: (brightness == Brightness.dark
? const Color(0xFF121212)
: Colors.white),
systemNavigationBarIconBrightness:
brightness == Brightness.dark ? Brightness.light : Brightness.dark,
)); ));
} }

View File

@@ -0,0 +1,248 @@
/// 결제 주기에 따른 비용 변환 유틸리티
class BillingCostUtil {
/// 결제 주기별 비용을 월 비용으로 변환
///
/// [amount]: 입력된 비용
/// [billingCycle]: 결제 주기 ('monthly', 'yearly', 'quarterly', 'half-yearly' 등)
///
/// Returns: 월 환산 비용
static double convertToMonthlyCost(double amount, String billingCycle) {
final normalizedCycle = _normalizeBillingCycle(billingCycle);
switch (normalizedCycle) {
case 'monthly':
return amount;
case 'yearly':
return amount / 12;
case 'quarterly':
return amount / 3;
case 'half-yearly':
return amount / 6;
case 'weekly':
return amount * 4.33; // 평균 주당 4.33주
default:
return amount; // 알 수 없는 주기는 그대로 반환
}
}
/// 월 비용을 결제 주기별 비용으로 역변환
///
/// [monthlyCost]: 월 비용
/// [billingCycle]: 결제 주기
///
/// Returns: 해당 주기의 실제 결제 금액
static double convertFromMonthlyCost(double monthlyCost, String billingCycle) {
final normalizedCycle = _normalizeBillingCycle(billingCycle);
switch (normalizedCycle) {
case 'monthly':
return monthlyCost;
case 'yearly':
return monthlyCost * 12;
case 'quarterly':
return monthlyCost * 3;
case 'half-yearly':
return monthlyCost * 6;
case 'weekly':
return monthlyCost / 4.33;
default:
return monthlyCost;
}
}
/// 결제 주기를 정규화된 영어 키값으로 변환
static String _normalizeBillingCycle(String cycle) {
switch (cycle.toLowerCase()) {
case 'monthly':
case '월간':
case '매월':
case '月間':
case '月付':
case '每月':
case '毎月':
return 'monthly';
case 'yearly':
case 'annual':
case 'annually':
case '연간':
case '매년':
case '年間':
case '年付':
case '每年':
return 'yearly';
case 'quarterly':
case 'quarter':
case '분기별':
case '분기':
case '季付':
case '季度付':
case '四半期':
case '每季度':
return 'quarterly';
case 'half-yearly':
case 'half yearly':
case 'semiannual':
case 'semi-annual':
case '반기별':
case '半年付':
case '半年払い':
case '半年ごと':
case '每半年':
return 'half-yearly';
case 'weekly':
case '주간':
case '週間':
case '周付':
case '每周':
return 'weekly';
default:
return 'monthly';
}
}
/// 결제 주기의 배수 반환 (월 기준)
///
/// 예: yearly = 12, quarterly = 3
static double getBillingCycleMultiplier(String billingCycle) {
final normalizedCycle = _normalizeBillingCycle(billingCycle);
switch (normalizedCycle) {
case 'monthly':
return 1.0;
case 'yearly':
return 12.0;
case 'quarterly':
return 3.0;
case 'half-yearly':
return 6.0;
case 'weekly':
return 1 / 4.33;
default:
return 1.0;
}
}
/// 다음 결제일에서 이전 결제일 계산
///
/// [nextBillingDate]: 다음 결제 예정일
/// [billingCycle]: 결제 주기
///
/// Returns: 이전 결제일 (마지막으로 결제가 발생한 날짜)
static DateTime getLastBillingDate(
DateTime nextBillingDate, String billingCycle) {
final normalizedCycle = _normalizeBillingCycle(billingCycle);
switch (normalizedCycle) {
case 'yearly':
return DateTime(
nextBillingDate.year - 1,
nextBillingDate.month,
nextBillingDate.day,
);
case 'half-yearly':
return DateTime(
nextBillingDate.month <= 6
? nextBillingDate.year - 1
: nextBillingDate.year,
nextBillingDate.month <= 6
? nextBillingDate.month + 6
: nextBillingDate.month - 6,
nextBillingDate.day,
);
case 'quarterly':
return DateTime(
nextBillingDate.month <= 3
? nextBillingDate.year - 1
: nextBillingDate.year,
nextBillingDate.month <= 3
? nextBillingDate.month + 9
: nextBillingDate.month - 3,
nextBillingDate.day,
);
case 'monthly':
return DateTime(
nextBillingDate.month == 1
? nextBillingDate.year - 1
: nextBillingDate.year,
nextBillingDate.month == 1 ? 12 : nextBillingDate.month - 1,
nextBillingDate.day,
);
case 'weekly':
return nextBillingDate.subtract(const Duration(days: 7));
default:
return DateTime(
nextBillingDate.month == 1
? nextBillingDate.year - 1
: nextBillingDate.year,
nextBillingDate.month == 1 ? 12 : nextBillingDate.month - 1,
nextBillingDate.day,
);
}
}
/// 특정 월에 결제가 발생하는지 확인
///
/// [nextBillingDate]: 다음 결제 예정일
/// [billingCycle]: 결제 주기
/// [targetYear]: 확인할 연도
/// [targetMonth]: 확인할 월 (1-12)
///
/// Returns: 해당 월에 결제가 발생하면 true
static bool hasBillingInMonth(
DateTime nextBillingDate,
String billingCycle,
int targetYear,
int targetMonth,
) {
final normalizedCycle = _normalizeBillingCycle(billingCycle);
// 주간 결제는 매주 발생하므로 항상 true
if (normalizedCycle == 'weekly') {
return true;
}
// 월간 결제는 매월 발생하므로 항상 true
if (normalizedCycle == 'monthly') {
return true;
}
// 결제 주기에 따른 개월 수
final cycleMonths = _getCycleMonths(normalizedCycle);
// 연도+월을 포함한 개월 차이 계산
// nextBillingDate와 target 월 사이의 차이가 cycleMonths의 배수인지 확인
final targetStart = DateTime(targetYear, targetMonth, 1);
final billingStart =
DateTime(nextBillingDate.year, nextBillingDate.month, 1);
final monthDiff = (billingStart.year - targetStart.year) * 12 +
(billingStart.month - targetStart.month);
// monthDiff가 cycleMonths의 배수이면 해당 월에 결제 발생
// 예: 연간 결제(2027-01), target=2026-01 → monthDiff=12, 12%12=0 → true (이전 결제)
// 예: 연간 결제(2027-01), target=2026-02 → monthDiff=11, 11%12≠0 → false
// 예: 연간 결제(2027-01), target=2027-01 → monthDiff=0, 0%12=0 → true
return monthDiff % cycleMonths == 0;
}
/// 결제 주기별 개월 수 반환
static int _getCycleMonths(String normalizedCycle) {
switch (normalizedCycle) {
case 'yearly':
return 12;
case 'half-yearly':
return 6;
case 'quarterly':
return 3;
case 'monthly':
return 1;
default:
return 1;
}
}
}

View File

@@ -12,6 +12,7 @@ import 'analysis_badge.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../providers/locale_provider.dart'; import '../../providers/locale_provider.dart';
import '../../utils/reduce_motion.dart'; import '../../utils/reduce_motion.dart';
import '../../utils/billing_cost_util.dart';
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯 /// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
class SubscriptionPieChartCard extends StatefulWidget { class SubscriptionPieChartCard extends StatefulWidget {
@@ -94,9 +95,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
// 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산) // 개별 구독의 비율 계산을 위한 값들 (기본 통화로 환산)
List<double> sectionValues = []; List<double> sectionValues = [];
// 각 구독의 현재 가격을 언어별 기본 통화로 환산 // 각 구독의 실제 결제 금액을 언어별 기본 통화로 환산
for (var subscription in widget.subscriptions) { for (var subscription in widget.subscriptions) {
double value = subscription.currentPrice; // 월 환산 금액을 실제 결제 금액으로 역변환
double value = BillingCostUtil.convertFromMonthlyCost(
subscription.currentPrice,
subscription.billingCycle,
);
if (subscription.currency == defaultCurrency) { if (subscription.currency == defaultCurrency) {
// 이미 기본 통화인 경우 그대로 사용 // 이미 기본 통화인 경우 그대로 사용

View File

@@ -235,7 +235,7 @@ class DetailHeaderSection extends StatelessWidget {
builder: (context, snapshot) { builder: (context, snapshot) {
return _InfoColumn( return _InfoColumn(
label: AppLocalizations.of(context) label: AppLocalizations.of(context)
.monthlyExpense, .billingAmount,
value: snapshot.data ?? '-', value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end, alignment: CrossAxisAlignment.end,
wrapValue: true, wrapValue: true,

View File

@@ -161,24 +161,14 @@ class _HomeContentState extends State<HomeContent> {
).animate(CurvedAnimation( ).animate(CurvedAnimation(
parent: widget.slideController, parent: widget.slideController,
curve: Curves.easeOutCubic)), curve: Curves.easeOutCubic)),
child: Row( child: Text(
children: [ AppLocalizations.of(context).subscriptionCount(
Text( subscriptionProvider.subscriptions.length),
AppLocalizations.of(context).subscriptionCount( style: TextStyle(
subscriptionProvider.subscriptions.length), fontSize: 14,
style: TextStyle( fontWeight: FontWeight.w600,
fontSize: 14, color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600, ),
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
],
), ),
), ),
], ],

View File

@@ -203,7 +203,7 @@ class MainScreenSummaryCard extends StatelessWidget {
// 연간 비용 및 총 구독 수 표시 // 연간 비용 및 총 구독 수 표시
FutureBuilder<double>( FutureBuilder<double>(
future: CurrencyUtil future: CurrencyUtil
.calculateTotalMonthlyExpenseInDefaultCurrency( .calculateTotalAnnualExpenseInDefaultCurrency(
provider.subscriptions, provider.subscriptions,
locale, locale,
), ),
@@ -211,8 +211,7 @@ class MainScreenSummaryCard extends StatelessWidget {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const SizedBox(); return const SizedBox();
} }
final monthlyCost = snapshot.data!; final yearlyCost = snapshot.data!;
final yearlyCost = monthlyCost * 12;
final decimals = (defaultCurrency == 'KRW' || final decimals = (defaultCurrency == 'KRW' ||
defaultCurrency == 'JPY') defaultCurrency == 'JPY')
? 0 ? 0

View File

@@ -7,12 +7,10 @@ import '../../l10n/app_localizations.dart';
class ScanInitialWidget extends StatelessWidget { class ScanInitialWidget extends StatelessWidget {
final VoidCallback onScanPressed; final VoidCallback onScanPressed;
final String? errorMessage;
const ScanInitialWidget({ const ScanInitialWidget({
super.key, super.key,
required this.onScanPressed, required this.onScanPressed,
this.errorMessage,
}); });
@override @override
@@ -24,15 +22,6 @@ class ScanInitialWidget extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: ThemedText(
errorMessage!,
color: Theme.of(context).colorScheme.error,
textAlign: TextAlign.center,
),
),
ThemedText( ThemedText(
AppLocalizations.of(context).findRepeatSubscriptions, AppLocalizations.of(context).findRepeatSubscriptions,
fontSize: 20, fontSize: 20,

View File

@@ -7,6 +7,7 @@ import '../providers/locale_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../services/currency_util.dart'; import '../services/currency_util.dart';
import '../utils/billing_date_util.dart'; import '../utils/billing_date_util.dart';
import '../utils/billing_cost_util.dart';
import '../utils/payment_card_utils.dart'; import '../utils/payment_card_utils.dart';
import 'website_icon.dart'; import 'website_icon.dart';
import 'app_navigator.dart'; import 'app_navigator.dart';
@@ -272,25 +273,41 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} }
} }
// 가격 포맷팅 함수 (언어별 통화) // 가격 포맷팅 함수 (언어별 통화) - 실제 결제 금액 표시
Future<String> _getFormattedPrice() async { Future<String> _getFormattedPrice() async {
final locale = context.read<LocaleProvider>().locale.languageCode; final locale = context.read<LocaleProvider>().locale.languageCode;
final billingCycle = widget.subscription.billingCycle;
if (widget.subscription.isCurrentlyInEvent) { if (widget.subscription.isCurrentlyInEvent) {
// 이벤트 중인 경우 원래 가격과 현재 가격 모두 표시 // 이벤트 중인 경우: 월 비용을 실제 결제 금액으로 역변환
final originalPrice = await CurrencyUtil.formatAmountWithLocale( final actualOriginalPrice = BillingCostUtil.convertFromMonthlyCost(
widget.subscription.monthlyCost, widget.subscription.monthlyCost,
billingCycle,
);
final actualCurrentPrice = BillingCostUtil.convertFromMonthlyCost(
widget.subscription.currentPrice,
billingCycle,
);
final originalPrice = await CurrencyUtil.formatAmountWithLocale(
actualOriginalPrice,
widget.subscription.currency, widget.subscription.currency,
locale, locale,
); );
final currentPrice = await CurrencyUtil.formatAmountWithLocale( final currentPrice = await CurrencyUtil.formatAmountWithLocale(
widget.subscription.currentPrice, actualCurrentPrice,
widget.subscription.currency, widget.subscription.currency,
locale, locale,
); );
return '$originalPrice|$currentPrice'; return '$originalPrice|$currentPrice';
} else { } else {
return CurrencyUtil.formatAmountWithLocale( // 월 비용을 실제 결제 금액으로 역변환 (연간이면 x12, 분기면 x3 등)
final actualPrice = BillingCostUtil.convertFromMonthlyCost(
widget.subscription.currentPrice, widget.subscription.currentPrice,
billingCycle,
);
return CurrencyUtil.formatAmountWithLocale(
actualPrice,
widget.subscription.currency, widget.subscription.currency,
locale, locale,
); );

View File

@@ -12,6 +12,7 @@ import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart'; import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
import '../utils/billing_cost_util.dart';
import '../utils/subscription_grouping_helper.dart'; import '../utils/subscription_grouping_helper.dart';
import 'native_ad_widget.dart'; import 'native_ad_widget.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
@@ -159,12 +160,17 @@ class SubscriptionListWidget extends StatelessWidget {
); );
} }
/// 특정 통화의 총 합계를 계산합니다. /// 특정 통화의 실제 결제 금액 총 합계를 계산합니다.
double _calculateTotalByCurrency( double _calculateTotalByCurrency(
List<SubscriptionModel> subscriptions, String currency) { List<SubscriptionModel> subscriptions, String currency) {
return subscriptions return subscriptions.where((sub) => sub.currency == currency).fold(
.where((sub) => sub.currency == currency) 0.0,
.fold(0.0, (sum, sub) => sum + sub.monthlyCost); (sum, sub) =>
sum +
BillingCostUtil.convertFromMonthlyCost(
sub.currentPrice,
sub.billingCycle,
));
} }
} }

View File

@@ -8,6 +8,7 @@ import Foundation
import flutter_local_notifications import flutter_local_notifications
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import local_auth_darwin import local_auth_darwin
import package_info_plus
import path_provider_foundation import path_provider_foundation
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -733,6 +733,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path: path:
dependency: transitive dependency: transitive
description: description:

View File

@@ -1,7 +1,7 @@
name: submanager name: submanager
description: A new Flutter project. description: A new Flutter project.
publish_to: 'none' publish_to: 'none'
version: 1.0.2+4 version: 1.0.8+10
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -42,6 +42,7 @@ dependencies:
crypto: ^3.0.6 crypto: ^3.0.6
image: ^4.5.4 image: ^4.5.4
google_mobile_ads: ^6.0.0 google_mobile_ads: ^6.0.0
package_info_plus: ^8.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: