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:
@@ -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); // 성공 여부 반환
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user