Compare commits
19 Commits
48b2063499
...
codex/feat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e797f6c1 | ||
|
|
903906c880 | ||
|
|
5de33992a2 | ||
|
|
cc8bcc7b54 | ||
|
|
9a950ee6c7 | ||
|
|
88569a57bf | ||
|
|
7125a4745a | ||
|
|
8d6b24ed6f | ||
|
|
0db1f12b40 | ||
|
|
595513b2e6 | ||
|
|
98488dbcd5 | ||
|
|
18a0004d57 | ||
|
|
6e7a7d2477 | ||
|
|
a0b24f9a75 | ||
|
|
58c00443c1 | ||
|
|
da530a99b7 | ||
|
|
0f92206833 | ||
|
|
db93c14105 | ||
|
|
c8c4746f52 |
@@ -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 코드는 제거되었습니다
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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": "活动价格",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
|
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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를 위한 충분한 하단 여백
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
248
lib/utils/billing_cost_util.dart
Normal file
248
lib/utils/billing_cost_util.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
// 이미 기본 통화인 경우 그대로 사용
|
// 이미 기본 통화인 경우 그대로 사용
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user