diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index 464dabe..d790852 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -12,6 +12,7 @@ class NotificationProvider extends ChangeNotifier { static const String _reminderHourKey = 'reminder_hour'; static const String _reminderMinuteKey = 'reminder_minute'; static const String _dailyReminderKey = 'daily_reminder_enabled'; + static const String _firstPermissionGrantedKey = 'first_permission_granted'; bool _isEnabled = false; bool _isPaymentEnabled = true; @@ -85,6 +86,12 @@ class NotificationProvider extends ChangeNotifier { try { _isEnabled = value; await NotificationService.setNotificationEnabled(value); + + // 첫 권한 부여 시 기본 설정 적용 + if (value) { + await initializeDefaultSettingsOnFirstPermission(); + } + notifyListeners(); } catch (e) { debugPrint('알림 활성화 설정 중 오류 발생: $e'); @@ -259,4 +266,22 @@ class NotificationProvider extends ChangeNotifier { // 오류가 발생해도 앱 동작에 영향을 주지 않도록 처리 } } + + // 첫 권한 부여 시 기본 설정 초기화 + Future initializeDefaultSettingsOnFirstPermission() async { + try { + final firstGranted = await _secureStorage.read(key: _firstPermissionGrantedKey); + if (firstGranted != 'true') { + // 첫 권한 부여 시 기본값 설정 + await setReminderDays(2); // 2일 전 알림 + await setDailyReminderEnabled(true); // 반복 알림 활성화 + await setPaymentEnabled(true); // 결제 예정 알림 활성화 + + // 첫 권한 부여 플래그 저장 + await _secureStorage.write(key: _firstPermissionGrantedKey, value: 'true'); + } + } catch (e) { + debugPrint('기본 설정 초기화 중 오류 발생: $e'); + } + } } diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 9edeb8e..08acd3a 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -4,6 +4,7 @@ import 'package:hive/hive.dart'; import 'package:uuid/uuid.dart'; import '../models/subscription_model.dart'; import '../services/notification_service.dart'; +import '../services/exchange_rate_service.dart'; import 'category_provider.dart'; class SubscriptionProvider extends ChangeNotifier { @@ -15,9 +16,19 @@ class SubscriptionProvider extends ChangeNotifier { bool get isLoading => _isLoading; double get totalMonthlyExpense { + final exchangeRateService = ExchangeRateService(); + final rate = exchangeRateService.cachedUsdToKrwRate ?? + ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; + return _subscriptions.fold( 0.0, - (sum, subscription) => sum + subscription.currentPrice, // 이벤트 가격 반영 + (sum, subscription) { + final price = subscription.currentPrice; + if (subscription.currency == 'USD') { + return sum + (price * rate); + } + return sum + price; + }, ); } @@ -44,6 +55,9 @@ class SubscriptionProvider extends ChangeNotifier { _isLoading = true; notifyListeners(); + // 환율 정보 미리 로드 + await ExchangeRateService().getUsdToKrwRate(); + _subscriptionBox = await Hive.openBox('subscriptions'); await refreshSubscriptions(); diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index c98444e..004785d 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -107,10 +107,7 @@ class _AnalysisScreenState extends State // 네이티브 광고 위젯 SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: _buildAnimatedAd(), - ), + child: _buildAnimatedAd(), ), const AnalysisScreenSpacer(), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c32f70d..2c7127c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -67,355 +67,391 @@ class SettingsScreen extends StatelessWidget { ); } - - @override Widget build(BuildContext context) { return Column( children: [ - SizedBox( - height: kToolbarHeight + MediaQuery.of(context).padding.top, - ), Expanded( child: ListView( - padding: const EdgeInsets.only(top: 20), - children: [ - // 광고 위젯 추가 - const NativeAdWidget(key: ValueKey('settings_ad')), - const SizedBox(height: 16), - // 앱 잠금 설정 UI 숨김 - // Card( - // margin: const EdgeInsets.all(16), - // child: Consumer( - // builder: (context, provider, child) { - // return SwitchListTile( - // title: const Text('앱 잠금'), - // subtitle: const Text('생체 인증으로 앱 잠금'), - // value: provider.isEnabled, - // onChanged: (value) async { - // if (value) { - // final isAuthenticated = await provider.authenticate(); - // if (isAuthenticated) { - // provider.enable(); - // } - // } else { - // provider.disable(); - // } - // }, - // ); - // }, - // ), - // ), + padding: EdgeInsets.zero, + children: [ + // toolbar 높이 추가 + SizedBox( + height: kToolbarHeight + MediaQuery.of(context).padding.top, + ), + // 광고 위젯 추가 + const NativeAdWidget(key: ValueKey('settings_ad')), + const SizedBox(height: 16), + // 앱 잠금 설정 UI 숨김 + // Card( + // margin: const EdgeInsets.all(16), + // child: Consumer( + // builder: (context, provider, child) { + // return SwitchListTile( + // title: const Text('앱 잠금'), + // subtitle: const Text('생체 인증으로 앱 잠금'), + // value: provider.isEnabled, + // onChanged: (value) async { + // if (value) { + // final isAuthenticated = await provider.authenticate(); + // if (isAuthenticated) { + // provider.enable(); + // } + // } else { + // provider.disable(); + // } + // }, + // ); + // }, + // ), + // ), - // 알림 설정 - GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: Consumer( - builder: (context, provider, child) { - return Column( - children: [ - ListTile( - title: const Text('알림 권한'), - subtitle: const Text('알림을 받으려면 권한이 필요합니다'), - trailing: ElevatedButton( - onPressed: () async { - final granted = - await NotificationService.requestPermission(); - if (granted) { - provider.setEnabled(true); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '알림 권한이 거부되었습니다', - style: TextStyle( - color: AppColors.pureWhite, - ), - ), - backgroundColor: AppColors.dangerColor, - ), - ); - } - }, - child: const Text('권한 요청'), - ), - ), - const Divider(), - // 결제 예정 알림 기본 스위치 - SwitchListTile( - title: const Text('결제 예정 알림'), - subtitle: const Text('결제 예정일 알림 받기'), - value: provider.isPaymentEnabled, - onChanged: (value) { - provider.setPaymentEnabled(value); - }, - ), - - // 알림 세부 설정 (알림 활성화된 경우에만 표시) - AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - child: provider.isPaymentEnabled - ? Padding( - padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 8.0), - child: Card( - elevation: 0, - color: Theme.of(context) - .colorScheme - .surfaceVariant - .withValues(alpha: 0.3), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - // 알림 시점 선택 (1일전, 2일전, 3일전) - const Text('알림 시점', - style: TextStyle( - fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - _buildReminderDayRadio( - context, provider, 1, '1일 전'), - _buildReminderDayRadio( - context, provider, 2, '2일 전'), - _buildReminderDayRadio( - context, provider, 3, '3일 전'), - ], - ), + // 알림 설정 + GlassmorphismCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), + child: Consumer( + builder: (context, provider, child) { + return Column( + children: [ + ListTile( + title: const Text( + '알림 권한', + style: TextStyle(color: AppColors.textPrimary), + ), + subtitle: const Text( + '알림을 받으려면 권한이 필요합니다', + style: TextStyle(color: AppColors.textSecondary), + ), + trailing: ElevatedButton( + onPressed: () async { + final granted = + await NotificationService.requestPermission(); + if (granted) { + await provider.setEnabled(true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '알림 권한이 거부되었습니다', + style: TextStyle( + color: AppColors.pureWhite, ), + ), + backgroundColor: AppColors.dangerColor, + ), + ); + } + }, + child: const Text('권한 요청'), + ), + ), + const Divider(), + // 결제 예정 알림 기본 스위치 + SwitchListTile( + title: const Text( + '결제 예정 알림', + style: TextStyle(color: AppColors.textPrimary), + ), + subtitle: const Text( + '결제 예정일 알림 받기', + style: TextStyle(color: AppColors.textSecondary), + ), + value: provider.isPaymentEnabled, + onChanged: (value) { + provider.setPaymentEnabled(value); + }, + ), - const SizedBox(height: 16), - - // 알림 시간 선택 - const Text('알림 시간', - style: TextStyle( - fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - InkWell( - onTap: () async { - final TimeOfDay? picked = - await showTimePicker( - context: context, - initialTime: TimeOfDay( - hour: provider.reminderHour, - minute: - provider.reminderMinute), - ); - if (picked != null) { - provider.setReminderTime( - picked.hour, picked.minute); - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 16), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5), + // 알림 세부 설정 (알림 활성화된 경우에만 표시) + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: provider.isPaymentEnabled + ? Padding( + padding: const EdgeInsets.only( + left: 16.0, right: 16.0, bottom: 8.0), + child: Card( + elevation: 0, + color: Theme.of(context) + .colorScheme + .surfaceVariant + .withValues(alpha: 0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + // 알림 시점 선택 (1일전, 2일전, 3일전) + const Text('알림 시점', + style: TextStyle( + fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + _buildReminderDayRadio(context, + provider, 1, '1일 전'), + _buildReminderDayRadio(context, + provider, 2, '2일 전'), + _buildReminderDayRadio(context, + provider, 3, '3일 전'), + ], ), - borderRadius: - BorderRadius.circular(8), ), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - Icon( - Icons.access_time, - color: Theme.of(context) - .colorScheme - .primary, - size: 22, + + const SizedBox(height: 16), + + // 알림 시간 선택 + const Text('알림 시간', + style: TextStyle( + fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + InkWell( + onTap: () async { + final TimeOfDay? picked = + await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: provider.reminderHour, + minute: provider + .reminderMinute), + ); + if (picked != null) { + provider.setReminderTime( + picked.hour, picked.minute); + } + }, + child: Container( + padding: + const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + borderRadius: + BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Icon( + Icons.access_time, + color: + Theme.of(context) + .colorScheme + .primary, + size: 22, + ), + const SizedBox( + width: 12), + Text( + '${provider.reminderHour.toString().padLeft(2, '0')}:${provider.reminderMinute.toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 16, + fontWeight: + FontWeight.bold, + color: Theme.of( + context) + .colorScheme + .onSurface, + ), + ), + ], ), - const SizedBox(width: 12), - Text( - '${provider.reminderHour.toString().padLeft(2, '0')}:${provider.reminderMinute.toString().padLeft(2, '0')}', - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight.bold, - color: Theme.of(context) - .colorScheme - .onSurface, - ), - ), - ], + ), + Icon( + Icons.arrow_forward_ios, + size: 16, + color: Theme.of(context) + .colorScheme + .outline, + ), + ], + ), + ), + ), + + // 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화) + if (provider.reminderDays >= 2) + Padding( + padding: const EdgeInsets.only( + top: 16.0), + child: Container( + padding: + const EdgeInsets.symmetric( + vertical: 4, + horizontal: 4), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceVariant + .withValues(alpha: 0.3), + borderRadius: + BorderRadius.circular(8), + ), + child: SwitchListTile( + contentPadding: + const EdgeInsets + .symmetric( + horizontal: 12), + title: + const Text('1일마다 반복 알림'), + subtitle: Text( + provider.isDailyReminderEnabled + ? '결제일까지 매일 알림을 받습니다' + : '결제 ${provider.reminderDays}일 전에 알림을 받습니다', + style: TextStyle( + color: AppColors + .textLight), + ), + value: provider + .isDailyReminderEnabled, + activeColor: Theme.of(context) + .colorScheme + .primary, + onChanged: (value) { + provider + .setDailyReminderEnabled( + value); + }, ), ), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Theme.of(context) - .colorScheme - .outline, - ), - ], - ), - ), + ), + ], ), - - // 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화) - if (provider.reminderDays >= 2) - Padding( - padding: - const EdgeInsets.only(top: 16.0), - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 4, horizontal: 4), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceVariant - .withValues(alpha: 0.3), - borderRadius: - BorderRadius.circular(8), - ), - child: SwitchListTile( - contentPadding: - const EdgeInsets.symmetric( - horizontal: 12), - title: const Text('1일마다 반복 알림'), - subtitle: const Text( - '결제일까지 매일 알림을 받습니다'), - value: provider - .isDailyReminderEnabled, - activeColor: Theme.of(context) - .colorScheme - .primary, - onChanged: (value) { - provider - .setDailyReminderEnabled( - value); - }, - ), - ), - ), - ], + ), ), + ) + : const SizedBox.shrink(), + ), + // 미사용 서비스 알림 기능 비활성화 + // const Divider(), + // SwitchListTile( + // title: const Text('미사용 서비스 알림'), + // subtitle: const Text('2개월 이상 미사용 시 알림'), + // value: provider.isUnusedServiceNotificationEnabled, + // onChanged: (value) { + // provider.setUnusedServiceNotificationEnabled(value); + // }, + // ), + ], + ); + }, + ), + ), + + // 앱 정보 + GlassmorphismCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), + child: ListTile( + title: const Text( + '앱 정보', + style: TextStyle(color: AppColors.textPrimary), + ), + subtitle: const Text( + '버전 1.0.0', + style: TextStyle(color: AppColors.textSecondary), + ), + leading: const Icon( + Icons.info, + color: AppColors.textSecondary, + ), + onTap: () async { + // 웹 환경에서는 기본 다이얼로그 표시 + if (kIsWeb) { + showAboutDialog( + context: context, + applicationName: 'Digital Rent Manager', + applicationVersion: '1.0.0', + applicationIcon: const FlutterLogo(size: 50), + children: [ + const Text('디지털 월세 관리 앱'), + const SizedBox(height: 8), + const Text('개발자: Julian Sul'), + ], + ); + return; + } + + // 앱 스토어 링크 + String storeUrl = ''; + + // 플랫폼에 따라 스토어 링크 설정 + if (Platform.isAndroid) { + // Android - Google Play 스토어 링크 + storeUrl = + 'https://play.google.com/store/apps/details?id=com.submanager.app'; + } else if (Platform.isIOS) { + // iOS - App Store 링크 + storeUrl = + 'https://apps.apple.com/app/submanager/id123456789'; + } + + if (storeUrl.isNotEmpty) { + try { + final Uri url = Uri.parse(storeUrl); + await launchUrl(url, + mode: LaunchMode.externalApplication); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '스토어를 열 수 없습니다', + style: TextStyle( + color: AppColors.pureWhite, ), ), - ) - : const SizedBox.shrink(), - ), - // 미사용 서비스 알림 기능 비활성화 - // const Divider(), - // SwitchListTile( - // title: const Text('미사용 서비스 알림'), - // subtitle: const Text('2개월 이상 미사용 시 알림'), - // value: provider.isUnusedServiceNotificationEnabled, - // onChanged: (value) { - // provider.setUnusedServiceNotificationEnabled(value); - // }, - // ), - ], - ); - }, - ), - ), - - // 앱 정보 - GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: ListTile( - title: const Text('앱 정보'), - subtitle: const Text('버전 1.0.0'), - leading: const Icon(Icons.info), - onTap: () async { - // 웹 환경에서는 기본 다이얼로그 표시 - if (kIsWeb) { - showAboutDialog( - context: context, - applicationName: 'SubManager', - applicationVersion: '1.0.0', - applicationIcon: const FlutterLogo(size: 50), - children: [ - const Text('구독 관리 앱'), - const SizedBox(height: 8), - const Text('개발자: SubManager Team'), - ], - ); - return; - } - - // 앱 스토어 링크 - String storeUrl = ''; - - // 플랫폼에 따라 스토어 링크 설정 - if (Platform.isAndroid) { - // Android - Google Play 스토어 링크 - storeUrl = - 'https://play.google.com/store/apps/details?id=com.submanager.app'; - } else if (Platform.isIOS) { - // iOS - App Store 링크 - storeUrl = - 'https://apps.apple.com/app/submanager/id123456789'; - } - - if (storeUrl.isNotEmpty) { - try { - final Uri url = Uri.parse(storeUrl); - await launchUrl(url, mode: LaunchMode.externalApplication); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '스토어를 열 수 없습니다', - style: TextStyle( - color: AppColors.pureWhite, + backgroundColor: AppColors.dangerColor, ), - ), - backgroundColor: AppColors.dangerColor, - ), + ); + } + } + } else { + // 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시 + showAboutDialog( + context: context, + applicationName: 'SubManager', + applicationVersion: '1.0.0', + applicationIcon: const FlutterLogo(size: 50), + children: [ + const Text('구독 관리 앱'), + const SizedBox(height: 8), + const Text('개발자: SubManager Team'), + ], ); } - } - } else { - // 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시 - showAboutDialog( - context: context, - applicationName: 'SubManager', - applicationVersion: '1.0.0', - applicationIcon: const FlutterLogo(size: 50), - children: [ - const Text('구독 관리 앱'), - const SizedBox(height: 8), - const Text('개발자: SubManager Team'), - ], - ); - } - }, - ), - ), - // FloatingNavigationBar를 위한 충분한 하단 여백 - SizedBox( - height: 120 + MediaQuery.of(context).padding.bottom, - ), - ], + }, + ), + ), + // FloatingNavigationBar를 위한 충분한 하단 여백 + SizedBox( + height: 120 + MediaQuery.of(context).padding.bottom, + ), + ], ), ), ], ); } - + String _getThemeModeText(AppThemeMode mode) { switch (mode) { case AppThemeMode.light: @@ -428,7 +464,7 @@ class SettingsScreen extends StatelessWidget { return '시스템 설정'; } } - + IconData _getThemeModeIcon(AppThemeMode mode) { switch (mode) { case AppThemeMode.light: diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index e7f1d78..e75e5f8 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -366,6 +366,10 @@ class _SmsScanScreenState extends State { final int safeRepeatCount = subscription.repeatCount > 0 ? subscription.repeatCount : 1; + // 카테고리 설정 로직 + final categoryId = _selectedCategoryId ?? subscription.category ?? _getDefaultCategoryId(); + print('카테고리 설정 - 선택된: $_selectedCategoryId, 자동매칭: ${subscription.category}, 최종: $categoryId'); + await provider.addSubscription( serviceName: subscription.serviceName, monthlyCost: subscription.monthlyCost, @@ -375,7 +379,7 @@ class _SmsScanScreenState extends State { isAutoDetected: true, repeatCount: safeRepeatCount, lastPaymentDate: subscription.lastPaymentDate, - categoryId: _selectedCategoryId ?? subscription.category, + categoryId: categoryId, currency: subscription.currency, // 통화 단위 정보 추가 ); @@ -587,12 +591,27 @@ class _SmsScanScreenState extends State { } } + // 기본 카테고리 ID (기타) 반환 + String _getDefaultCategoryId() { + final categoryProvider = Provider.of(context, listen: false); + final otherCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name == '기타', + orElse: () => categoryProvider.categories.first, // 만약 "기타"가 없으면 첫 번째 카테고리 + ); + print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})'); + return otherCategory.id; + } + @override Widget build(BuildContext context) { return SingleChildScrollView( - padding: const EdgeInsets.all(16.0), + padding: EdgeInsets.zero, child: Column( children: [ + // toolbar 높이 추가 + SizedBox( + height: kToolbarHeight + MediaQuery.of(context).padding.top, + ), _isLoading ? _buildLoadingState() : (_scannedSubscriptions.isEmpty @@ -609,18 +628,21 @@ class _SmsScanScreenState extends State { // 로딩 상태 UI Widget _buildLoadingState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), - ), - const SizedBox(height: 16), - const ThemedText('SMS 메시지를 스캔 중입니다...', forceDark: true), - const SizedBox(height: 8), - const ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7, forceDark: true), - ], + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), + ), + const SizedBox(height: 16), + const ThemedText('SMS 메시지를 스캔 중입니다...', forceDark: true), + const SizedBox(height: 8), + const ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7, forceDark: true), + ], + ), ), ); } @@ -633,45 +655,44 @@ class _SmsScanScreenState extends State { const NativeAdWidget(key: ValueKey('sms_scan_start_ad')), const SizedBox(height: 48), Padding( - padding: const EdgeInsets.symmetric(vertical: 32), + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (_errorMessage != null) - Padding( - padding: const EdgeInsets.all(16.0), - child: ThemedText( - _errorMessage!, - color: Colors.red, - textAlign: TextAlign.center, + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: ThemedText( + _errorMessage!, + color: Colors.red, + textAlign: TextAlign.center, + ), + ), + const ThemedText( + '2회 이상 결제된 구독 서비스 찾기', + fontSize: 20, + fontWeight: FontWeight.bold, + forceDark: true, + ), + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: ThemedText( + '문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.', + textAlign: TextAlign.center, + opacity: 0.7, + forceDark: true, + ), + ), + const SizedBox(height: 32), + PrimaryButton( + text: '스캔 시작하기', + icon: Icons.search_rounded, + onPressed: _scanSms, + width: 200, + height: 56, + backgroundColor: AppColors.primaryColor, ), - ), - const SizedBox(height: 24), - const ThemedText( - '2회 이상 결제된 구독 서비스 찾기', - fontSize: 20, - fontWeight: FontWeight.bold, - forceDark: true, - ), - const SizedBox(height: 16), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 32.0), - child: ThemedText( - '문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.', - textAlign: TextAlign.center, - opacity: 0.7, - forceDark: true, - ), - ), - const SizedBox(height: 32), - PrimaryButton( - text: '스캔 시작하기', - icon: Icons.search_rounded, - onPressed: _scanSms, - width: 200, - height: 56, - backgroundColor: AppColors.primaryColor, - ), ], ), ), @@ -702,26 +723,31 @@ class _SmsScanScreenState extends State { // 광고 위젯 추가 const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), const SizedBox(height: 16), - // 진행 상태 표시 - LinearProgressIndicator( - value: (_currentIndex + 1) / _scannedSubscriptions.length, - backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary), - ), - const SizedBox(height: 8), - ThemedText( - '${_currentIndex + 1}/${_scannedSubscriptions.length}', - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 진행 상태 표시 + LinearProgressIndicator( + value: (_currentIndex + 1) / _scannedSubscriptions.length, + backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 8), + ThemedText( + '${_currentIndex + 1}/${_scannedSubscriptions.length}', + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 24), - // 구독 정보 카드 - GlassmorphismCard( - width: double.infinity, - padding: const EdgeInsets.all(16.0), + // 구독 정보 카드 + GlassmorphismCard( + width: double.infinity, + padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -884,9 +910,12 @@ class _SmsScanScreenState extends State { ), ], ), - ], + ], + ), ), - ), + ], + ), + ), ], ); } diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index 978a470..c59651e 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -22,6 +22,12 @@ class ExchangeRateService { // API 요청 URL (ExchangeRate-API 사용) final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD'; + // 기본 환율 상수 + static const double DEFAULT_USD_TO_KRW_RATE = 1350.0; + + // 캐싱된 환율 반환 (동기적) + double? get cachedUsdToKrwRate => _usdToKrwRate; + /// 현재 USD to KRW 환율 정보를 가져옵니다. /// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 반환합니다. Future getUsdToKrwRate() async { diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 1bae993..3eac300 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -66,19 +66,38 @@ class _FloatingNavigationBarState extends State builder: (context, child) { return Positioned( bottom: 20, - left: 20, - right: 20, + left: 16, + right: 16, height: 88, child: Transform.translate( offset: Offset(0, 100 * (1 - _animation.value)), child: Opacity( opacity: _animation.value, - child: GlassmorphismCard( + child: Stack( + children: [ + // 흰색 배경 레이어 (완전 불투명) + Container( + decoration: BoxDecoration( + color: AppColors.surfaceColor, + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 20, + spreadRadius: -5, + offset: Offset(0, 10), + ), + ], + ), + ), + // 글래스모피즘 레이어 (시각적 효과) + GlassmorphismCard( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), borderRadius: 24, blur: 10.0, backgroundColor: Colors.transparent, + boxShadow: const [], // 그림자는 배경 레이어에서 처리 child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -112,6 +131,8 @@ class _FloatingNavigationBarState extends State ], ), ), + ], + ), ), ), ); diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index e2c037a..3a5427f 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../providers/subscription_provider.dart'; +import '../services/currency_util.dart'; import '../theme/app_colors.dart'; import 'animated_wave_background.dart'; import 'glassmorphism_card.dart'; @@ -35,9 +36,9 @@ class MainScreenSummaryCard extends StatelessWidget { opacity: Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), + padding: const EdgeInsets.fromLTRB(16, 23, 16, 12), child: GlassmorphismCard( - borderRadius: 24, + borderRadius: 16, blur: 15, backgroundColor: AppColors.glassCard, gradient: LinearGradient( @@ -78,14 +79,49 @@ class MainScreenSummaryCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - '이번 달 총 구독 비용', - style: TextStyle( - color: AppColors - .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 15, - fontWeight: FontWeight.w500, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '이번 달 총 구독 비용', + style: TextStyle( + color: AppColors + .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + FutureBuilder( + future: CurrencyUtil.getExchangeRateInfo(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFE5F2FF), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFFBFDBFE), + width: 1, + ), + ), + child: Text( + snapshot.data!, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], ), const SizedBox(height: 8), Row( diff --git a/lib/widgets/native_ad_widget.dart b/lib/widgets/native_ad_widget.dart index 5a18277..d0ccfab 100644 --- a/lib/widgets/native_ad_widget.dart +++ b/lib/widgets/native_ad_widget.dart @@ -187,7 +187,7 @@ class _NativeAdWidgetState extends State { // 광고 정상 노출 return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: GlassmorphismCard( borderRadius: 16, blur: 10,