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.
This commit is contained in:
JiWoong Sul
2025-09-15 11:37:38 +09:00
parent b944f6967d
commit d111b5dd62
2 changed files with 135 additions and 9 deletions

View File

@@ -8,6 +8,9 @@ import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart'; 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/business_day_util.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController { class AddSubscriptionController {
@@ -104,6 +107,26 @@ class AddSubscriptionController {
scrollOffset = scrollController.offset; 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(); animationController!.forward();
} }
@@ -284,25 +307,55 @@ class AddSubscriptionController {
setState(() => isLoading = true); setState(() => isLoading = true);
try { try {
final ctx = context;
if (!await SMSService.hasSMSPermission()) { if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission(); final granted = await SMSService.requestSMSPermission();
if (!ctx.mounted) return;
if (!granted) { if (!granted) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showError( // 영구 거부 여부 확인 후 설정 화면 안내
context: context, final status = await permission.Permission.sms.status;
message: AppLocalizations.of(context).smsPermissionRequired, 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; return;
} }
} }
final subscriptions = await SMSService.scanSubscriptions(); final subscriptions = await SMSService.scanSubscriptions();
if (!ctx.mounted) return;
if (subscriptions.isEmpty) { if (subscriptions.isEmpty) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showWarning( AppSnackBar.showWarning(
context: context, context: ctx,
message: AppLocalizations.of(context).noSubscriptionSmsFound, message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
); );
} }
return; return;
@@ -434,12 +487,22 @@ class AddSubscriptionController {
double.tryParse(eventPriceController.text.replaceAll(',', '')); 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<SubscriptionProvider>(context, listen: false) await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription( .addSubscription(
serviceName: serviceNameController.text.trim(), serviceName: serviceNameController.text.trim(),
monthlyCost: monthlyCost, monthlyCost: monthlyCost,
billingCycle: billingCycle, billingCycle: billingCycle,
nextBillingDate: nextBillingDate!, nextBillingDate: adjustedNext,
websiteUrl: websiteUrlController.text.trim(), websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId, categoryId: selectedCategoryId,
currency: currency, currency: currency,
@@ -449,6 +512,16 @@ class AddSubscriptionController {
eventPrice: eventPrice, eventPrice: eventPrice,
); );
// 자동 보정이 발생했으면 안내
if (adjustedNext.isAfter(originalDateOnly)) {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
);
}
}
if (context.mounted) { if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환 Navigator.pop(context, true); // 성공 여부 반환
} }

View File

@@ -5,6 +5,8 @@ import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart'; import '../services/sms_scan/subscription_filter.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import 'package:provider/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 '../utils/logger.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
@@ -57,6 +59,33 @@ class SmsScanController extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { 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 스캔 실행 // SMS 스캔 실행
Log.i('SMS 스캔 시작'); Log.i('SMS 스캔 시작');
final scannedSubscriptionModels = final scannedSubscriptionModels =
@@ -139,6 +168,30 @@ class SmsScanController extends ChangeNotifier {
} }
} }
Future<void> _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<void> addCurrentSubscription(BuildContext context) async { Future<void> addCurrentSubscription(BuildContext context) async {
if (_currentIndex >= _scannedSubscriptions.length) return; if (_currentIndex >= _scannedSubscriptions.length) return;