From d111b5dd620cff5530ac3689f95c89613223cf9c Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 15 Sep 2025 11:37:38 +0900 Subject: [PATCH] fix(sms-permission): re-request on denial and guide permanent denial to app settings Summary: Improve SMS permission UX so users can request again after denial and are guided to app settings when permanently denied.\nChanges: handle Permission.sms status in controllers, show settings dialog for permanently denied, use kIsWeb guard, context-safety across async.\nValidation: scripts/check.sh passed (analyze/tests OK).\nRisk & Rollback: low; scoped to permission request flow. Revert two controllers if issues. --- .../add_subscription_controller.dart | 91 +++++++++++++++++-- lib/controllers/sms_scan_controller.dart | 53 +++++++++++ 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart index 12c6d8f..9bdbecf 100644 --- a/lib/controllers/add_subscription_controller.dart +++ b/lib/controllers/add_subscription_controller.dart @@ -8,6 +8,9 @@ import '../services/sms_service.dart'; import '../services/subscription_url_matcher.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; +import '../utils/billing_date_util.dart'; +import '../utils/business_day_util.dart'; +import 'package:permission_handler/permission_handler.dart' as permission; /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller class AddSubscriptionController { @@ -104,6 +107,26 @@ class AddSubscriptionController { scrollOffset = scrollController.offset; }); + // 언어별 기본 통화 설정 + try { + final lang = Localizations.localeOf(context).languageCode; + switch (lang) { + case 'ko': + currency = 'KRW'; + break; + case 'ja': + currency = 'JPY'; + break; + case 'zh': + currency = 'CNY'; + break; + default: + currency = 'USD'; + } + } catch (_) { + // Localizations가 아직 준비되지 않은 경우 기본값 유지 + } + // 애니메이션 시작 animationController!.forward(); } @@ -284,25 +307,55 @@ class AddSubscriptionController { setState(() => isLoading = true); try { + final ctx = context; if (!await SMSService.hasSMSPermission()) { final granted = await SMSService.requestSMSPermission(); + if (!ctx.mounted) return; if (!granted) { - if (context.mounted) { - AppSnackBar.showError( - context: context, - message: AppLocalizations.of(context).smsPermissionRequired, - ); + if (ctx.mounted) { + // 영구 거부 여부 확인 후 설정 화면 안내 + final status = await permission.Permission.sms.status; + if (!ctx.mounted) return; + if (status.isPermanentlyDenied) { + await showDialog( + context: ctx, + builder: (_) => AlertDialog( + title: Text(AppLocalizations.of(ctx).smsPermissionRequired), + content: + Text(AppLocalizations.of(ctx).permanentlyDeniedMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(AppLocalizations.of(ctx).cancel), + ), + TextButton( + onPressed: () async { + await permission.openAppSettings(); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + child: Text(AppLocalizations.of(ctx).openSettings), + ), + ], + ), + ); + } else { + AppSnackBar.showError( + context: ctx, + message: AppLocalizations.of(ctx).smsPermissionRequired, + ); + } } return; } } final subscriptions = await SMSService.scanSubscriptions(); + if (!ctx.mounted) return; if (subscriptions.isEmpty) { - if (context.mounted) { + if (ctx.mounted) { AppSnackBar.showWarning( - context: context, - message: AppLocalizations.of(context).noSubscriptionSmsFound, + context: ctx, + message: AppLocalizations.of(ctx).noSubscriptionSmsFound, ); } return; @@ -434,12 +487,22 @@ class AddSubscriptionController { double.tryParse(eventPriceController.text.replaceAll(',', '')); } + // 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월 + final originalDateOnly = DateTime( + nextBillingDate!.year, + nextBillingDate!.month, + nextBillingDate!.day, + ); + var adjustedNext = + BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle); + adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext); + await Provider.of(context, listen: false) .addSubscription( serviceName: serviceNameController.text.trim(), monthlyCost: monthlyCost, billingCycle: billingCycle, - nextBillingDate: nextBillingDate!, + nextBillingDate: adjustedNext, websiteUrl: websiteUrlController.text.trim(), categoryId: selectedCategoryId, currency: currency, @@ -449,6 +512,16 @@ class AddSubscriptionController { eventPrice: eventPrice, ); + // 자동 보정이 발생했으면 안내 + if (adjustedNext.isAfter(originalDateOnly)) { + if (context.mounted) { + AppSnackBar.showInfo( + context: context, + message: '다음 결제 예정일로 저장됨', + ); + } + } + if (context.mounted) { Navigator.pop(context, true); // 성공 여부 반환 } diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart index 7e9da50..8cf526c 100644 --- a/lib/controllers/sms_scan_controller.dart +++ b/lib/controllers/sms_scan_controller.dart @@ -5,6 +5,8 @@ import '../services/sms_scan/subscription_converter.dart'; import '../services/sms_scan/subscription_filter.dart'; import '../providers/subscription_provider.dart'; import 'package:provider/provider.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:permission_handler/permission_handler.dart' as permission; import '../utils/logger.dart'; import '../providers/navigation_provider.dart'; import '../providers/category_provider.dart'; @@ -57,6 +59,33 @@ class SmsScanController extends ChangeNotifier { notifyListeners(); try { + // Android에서 SMS 권한 확인 및 요청 + final ctx = context; + if (!kIsWeb) { + final smsStatus = await permission.Permission.sms.status; + if (!smsStatus.isGranted) { + if (smsStatus.isPermanentlyDenied) { + // 설정 유도 다이얼로그 표시 + if (!ctx.mounted) return; + await _showPermissionSettingsDialog(ctx); + _isLoading = false; + notifyListeners(); + return; + } + + final req = await permission.Permission.sms.request(); + if (!ctx.mounted) return; + if (!req.isGranted) { + // 거부됨: 안내 후 종료 + if (!ctx.mounted) return; + _errorMessage = AppLocalizations.of(ctx).smsPermissionRequired; + _isLoading = false; + notifyListeners(); + return; + } + } + } + // SMS 스캔 실행 Log.i('SMS 스캔 시작'); final scannedSubscriptionModels = @@ -139,6 +168,30 @@ class SmsScanController extends ChangeNotifier { } } + Future _showPermissionSettingsDialog(BuildContext context) async { + final loc = AppLocalizations.of(context); + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(loc.smsPermissionRequired), + content: Text(loc.permanentlyDeniedMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(loc.cancel), + ), + TextButton( + onPressed: () async { + await permission.openAppSettings(); + if (context.mounted) Navigator.of(context).pop(); + }, + child: Text(loc.openSettings), + ), + ], + ), + ); + } + Future addCurrentSubscription(BuildContext context) async { if (_currentIndex >= _scannedSubscriptions.length) return;