diff --git a/docs/adr/0010-keep-delivery-enabled-with-fallback-notifications.md b/docs/adr/0010-keep-delivery-enabled-with-fallback-notifications.md new file mode 100644 index 00000000..6180581e --- /dev/null +++ b/docs/adr/0010-keep-delivery-enabled-with-fallback-notifications.md @@ -0,0 +1,3 @@ +# Keep Delivery Enabled With Fallback Notifications + +When exact alarm or AlarmKit permission is denied but notification fallback is available, OnTime will keep schedule notifications enabled and arm them through Fallback Notification delivery. Exact timing or AlarmKit recovery remains a non-blocking improvement path, while disabling schedule notifications is reserved for states where no native or fallback delivery path can currently notify the user. diff --git a/lib/domain/entities/alarm_delivery_policy.dart b/lib/domain/entities/alarm_delivery_policy.dart new file mode 100644 index 00000000..abf8eebf --- /dev/null +++ b/lib/domain/entities/alarm_delivery_policy.dart @@ -0,0 +1,114 @@ +import 'package:equatable/equatable.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; + +enum AlarmDeliveryMode { nativeAlarm, fallbackNotification, unavailable } + +class AlarmDeliveryPolicy extends Equatable { + const AlarmDeliveryPolicy._({ + required this.mode, + required this.activeProvider, + required this.blockingPermissionIssue, + required this.recoveryPermissionIssue, + }); + + factory AlarmDeliveryPolicy.evaluate({ + required AlarmSchedulerCapabilities capabilities, + required AlarmPermissionState nativePermission, + required AlarmPermissionState fallbackPermission, + }) { + if (capabilities.supportsNativeAlarm && + capabilities.nativeAlarmProvider != AlarmProvider.none && + nativePermission == AlarmPermissionState.granted) { + return AlarmDeliveryPolicy._( + mode: AlarmDeliveryMode.nativeAlarm, + activeProvider: capabilities.nativeAlarmProvider, + blockingPermissionIssue: null, + recoveryPermissionIssue: null, + ); + } + + if (capabilities.fallbackProvider == AlarmProvider.localNotification && + fallbackPermission == AlarmPermissionState.granted) { + return AlarmDeliveryPolicy._( + mode: AlarmDeliveryMode.fallbackNotification, + activeProvider: AlarmProvider.localNotification, + blockingPermissionIssue: null, + recoveryPermissionIssue: _nativeRecoveryIssue( + capabilities, + nativePermission, + ), + ); + } + + final blockingIssue = _blockingPermissionIssue( + capabilities, + nativePermission, + fallbackPermission, + ); + return AlarmDeliveryPolicy._( + mode: AlarmDeliveryMode.unavailable, + activeProvider: AlarmProvider.none, + blockingPermissionIssue: blockingIssue, + recoveryPermissionIssue: null, + ); + } + + final AlarmDeliveryMode mode; + final AlarmProvider activeProvider; + final AlarmPermissionIssue? blockingPermissionIssue; + final AlarmPermissionIssue? recoveryPermissionIssue; + + bool get canDeliver => activeProvider != AlarmProvider.none; + + bool get isUnsupported { + return mode == AlarmDeliveryMode.unavailable && + blockingPermissionIssue == null; + } + + bool get shouldDisableAlarms { + return mode == AlarmDeliveryMode.unavailable && + blockingPermissionIssue != null; + } + + @override + List get props => [ + mode, + activeProvider, + blockingPermissionIssue, + recoveryPermissionIssue, + ]; + + static AlarmPermissionIssue? _nativeRecoveryIssue( + AlarmSchedulerCapabilities capabilities, + AlarmPermissionState nativePermission, + ) { + if (!capabilities.supportsNativeAlarm || + capabilities.nativeAlarmProvider == AlarmProvider.none) { + return null; + } + if (nativePermission == AlarmPermissionState.denied || + nativePermission == AlarmPermissionState.notDetermined) { + return AlarmPermissionIssue.nativePermissionDenied; + } + return null; + } + + static AlarmPermissionIssue? _blockingPermissionIssue( + AlarmSchedulerCapabilities capabilities, + AlarmPermissionState nativePermission, + AlarmPermissionState fallbackPermission, + ) { + if (capabilities.supportsNativeAlarm && + capabilities.nativeAlarmProvider != AlarmProvider.none && + (nativePermission == AlarmPermissionState.denied || + nativePermission == AlarmPermissionState.notDetermined)) { + return AlarmPermissionIssue.nativePermissionDenied; + } + if (capabilities.fallbackProvider == AlarmProvider.localNotification && + (fallbackPermission == AlarmPermissionState.denied || + fallbackPermission == AlarmPermissionState.notDetermined)) { + return AlarmPermissionIssue.notificationPermissionDenied; + } + return null; + } +} diff --git a/lib/domain/use-cases/reconcile_alarms_use_case.dart b/lib/domain/use-cases/reconcile_alarms_use_case.dart index ed282095..f83409c1 100644 --- a/lib/domain/use-cases/reconcile_alarms_use_case.dart +++ b/lib/domain/use-cases/reconcile_alarms_use_case.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/entities/schedule_with_preparation_entity.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; @@ -217,6 +218,11 @@ class ReconcileAlarmsUseCase { '$_logTag permissions native=$nativePermission ' 'fallback=$fallbackPermission', ); + final deliveryPolicy = AlarmDeliveryPolicy.evaluate( + capabilities: capabilities, + nativePermission: nativePermission, + fallbackPermission: fallbackPermission, + ); final finalRecords = []; final failures = []; @@ -238,7 +244,7 @@ class ReconcileAlarmsUseCase { final scheduled = await _scheduleRecord( desired, capabilities: capabilities, - nativePermission: nativePermission, + deliveryPolicy: deliveryPolicy, fallbackPermission: fallbackPermission, ); @@ -282,8 +288,7 @@ class ReconcileAlarmsUseCase { armedCount: finalRecords.length, failures: failures, permissionIssue: permissionIssue, - capabilities: capabilities, - fallbackPermission: fallbackPermission, + deliveryPolicy: deliveryPolicy, ); final result = _result( @@ -371,12 +376,10 @@ class ReconcileAlarmsUseCase { Future<_ScheduleAttempt> _scheduleRecord( ScheduledAlarmRecord desired, { required AlarmSchedulerCapabilities capabilities, - required AlarmPermissionState nativePermission, + required AlarmDeliveryPolicy deliveryPolicy, required AlarmPermissionState fallbackPermission, }) async { - if (capabilities.supportsNativeAlarm && - capabilities.nativeAlarmProvider != AlarmProvider.none && - nativePermission == AlarmPermissionState.granted) { + if (deliveryPolicy.mode == AlarmDeliveryMode.nativeAlarm) { final nativeRecord = desired.copyWith( provider: capabilities.nativeAlarmProvider, ); @@ -415,7 +418,8 @@ class ReconcileAlarmsUseCase { } } - if (capabilities.fallbackProvider == AlarmProvider.localNotification && + if ((deliveryPolicy.mode == AlarmDeliveryMode.fallbackNotification || + capabilities.fallbackProvider == AlarmProvider.localNotification) && fallbackPermission == AlarmPermissionState.granted) { final fallbackRecord = desired.copyWith( provider: AlarmProvider.localNotification, @@ -455,17 +459,9 @@ class ReconcileAlarmsUseCase { } } - if (nativePermission == AlarmPermissionState.denied || - nativePermission == AlarmPermissionState.notDetermined) { - return const _ScheduleAttempt( - permissionIssue: AlarmPermissionIssue.nativePermissionDenied, - ); - } - if (fallbackPermission == AlarmPermissionState.denied || - fallbackPermission == AlarmPermissionState.notDetermined) { - return const _ScheduleAttempt( - permissionIssue: AlarmPermissionIssue.notificationPermissionDenied, - ); + final blockingIssue = deliveryPolicy.blockingPermissionIssue; + if (blockingIssue != null) { + return _ScheduleAttempt(permissionIssue: blockingIssue); } return const _ScheduleAttempt( @@ -479,17 +475,15 @@ class ReconcileAlarmsUseCase { required int armedCount, required List failures, required AlarmPermissionIssue? permissionIssue, - required AlarmSchedulerCapabilities capabilities, - required AlarmPermissionState fallbackPermission, + required AlarmDeliveryPolicy deliveryPolicy, }) { - final hasAnyProvider = - capabilities.supportsNativeAlarm || - fallbackPermission == AlarmPermissionState.granted; - if (!hasAnyProvider) { + if (!deliveryPolicy.canDeliver) { if (permissionIssue != null) { return AlarmReconciliationStatus.permissionNeeded; } - return AlarmReconciliationStatus.unsupported; + return deliveryPolicy.isUnsupported + ? AlarmReconciliationStatus.unsupported + : AlarmReconciliationStatus.permissionNeeded; } if (desiredCount > armedCount || failures.isNotEmpty) { return armedCount == 0 && permissionIssue != null diff --git a/lib/presentation/app/cubit/alarm_gate_cubit.dart b/lib/presentation/app/cubit/alarm_gate_cubit.dart index 5455ef58..01cc067f 100644 --- a/lib/presentation/app/cubit/alarm_gate_cubit.dart +++ b/lib/presentation/app/cubit/alarm_gate_cubit.dart @@ -4,6 +4,7 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/logging/app_logger.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; +import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/repositories/alarm_repository.dart'; import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; @@ -45,14 +46,14 @@ class AlarmGateCubit extends Cubit { bool enableAlarmsOnGrant = false, }) async { final capabilities = await _alarmSchedulerService.getCapabilities(); - final permission = await _checkEffectivePermission(capabilities); - if (permission == AlarmPermissionState.unsupported) { + final delivery = await _checkDeliveryPolicy(capabilities); + if (delivery.policy.isUnsupported) { emit(const AlarmGateState.unsupported()); return; } final prefs = await SharedPreferences.getInstance(); - if (permission == AlarmPermissionState.granted) { + if (delivery.policy.canDeliver) { await prefs.remove(_dismissedKey); if (enableAlarmsOnGrant) { await _enableAlarmsBestEffort(); @@ -62,7 +63,7 @@ class AlarmGateCubit extends Cubit { } if (disableAlarmsWhenPermissionMissing && - !await _hasGrantedFallbackPermission(capabilities)) { + delivery.policy.shouldDisableAlarms) { await _disableAlarmsBestEffort(); } @@ -76,13 +77,14 @@ class AlarmGateCubit extends Cubit { Future requestPermission() async { final capabilities = await _alarmSchedulerService.getCapabilities(); - final permission = await _requestEffectivePermission(capabilities); - if (permission == AlarmPermissionState.unsupported) { + final delivery = await _requestDeliveryPolicy(capabilities); + final permission = delivery.permissionState; + if (delivery.policy.isUnsupported) { emit(const AlarmGateState.unsupported()); return permission; } - if (permission == AlarmPermissionState.granted) { + if (delivery.policy.canDeliver) { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_dismissedKey); await _enableAlarmsBestEffort(); @@ -90,43 +92,43 @@ class AlarmGateCubit extends Cubit { return permission; } - if (!await _hasGrantedFallbackPermission(capabilities)) { + if (delivery.policy.shouldDisableAlarms) { await _disableAlarmsBestEffort(); } emit(const AlarmGateState.required()); return permission; } - Future _checkEffectivePermission( + Future<_EvaluatedAlarmDelivery> _checkDeliveryPolicy( AlarmSchedulerCapabilities capabilities, ) async { final nativePermission = await _checkNativePermission(capabilities); - if (nativePermission == AlarmPermissionState.granted) { - return nativePermission; - } - - if (nativePermission == AlarmPermissionState.unsupported) { - return _checkFallbackPermission(capabilities); - } - return nativePermission; + final fallbackPermission = await _checkFallbackPermission(capabilities); + return _EvaluatedAlarmDelivery( + policy: AlarmDeliveryPolicy.evaluate( + capabilities: capabilities, + nativePermission: nativePermission, + fallbackPermission: fallbackPermission, + ), + nativePermission: nativePermission, + fallbackPermission: fallbackPermission, + ); } - Future _requestEffectivePermission( + Future<_EvaluatedAlarmDelivery> _requestDeliveryPolicy( AlarmSchedulerCapabilities capabilities, ) async { final nativePermission = await _requestNativePermission(capabilities); - if (nativePermission == AlarmPermissionState.granted) { - return nativePermission; - } - - if (nativePermission == AlarmPermissionState.unsupported) { - return _requestFallbackPermission(capabilities); - } final fallbackPermission = await _requestFallbackPermission(capabilities); - if (fallbackPermission == AlarmPermissionState.granted) { - return fallbackPermission; - } - return nativePermission; + return _EvaluatedAlarmDelivery( + policy: AlarmDeliveryPolicy.evaluate( + capabilities: capabilities, + nativePermission: nativePermission, + fallbackPermission: fallbackPermission, + ), + nativePermission: nativePermission, + fallbackPermission: fallbackPermission, + ); } Future _checkNativePermission( @@ -171,7 +173,8 @@ class AlarmGateCubit extends Cubit { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_dismissedKey, true); final capabilities = await _alarmSchedulerService.getCapabilities(); - if (!await _hasGrantedFallbackPermission(capabilities)) { + final delivery = await _checkDeliveryPolicy(capabilities); + if (delivery.policy.shouldDisableAlarms) { await _disableAlarmsBestEffort(); } emit(const AlarmGateState.dismissed()); @@ -183,13 +186,6 @@ class AlarmGateCubit extends Cubit { capabilities.nativeAlarmProvider == AlarmProvider.iosAlarmKit; } - Future _hasGrantedFallbackPermission( - AlarmSchedulerCapabilities capabilities, - ) async { - return await _checkFallbackPermission(capabilities) == - AlarmPermissionState.granted; - } - Future _enableAlarmsBestEffort() async { try { await _alarmRepository.updateAlarmSettings(alarmsEnabled: true); @@ -212,3 +208,25 @@ class AlarmGateCubit extends Cubit { } } } + +class _EvaluatedAlarmDelivery { + const _EvaluatedAlarmDelivery({ + required this.policy, + required this.nativePermission, + required this.fallbackPermission, + }); + + final AlarmDeliveryPolicy policy; + final AlarmPermissionState nativePermission; + final AlarmPermissionState fallbackPermission; + + AlarmPermissionState get permissionState { + if (policy.canDeliver) return AlarmPermissionState.granted; + if (policy.isUnsupported) return AlarmPermissionState.unsupported; + if (policy.blockingPermissionIssue == + AlarmPermissionIssue.notificationPermissionDenied) { + return fallbackPermission; + } + return nativePermission; + } +} diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 97c35f80..496e87bf 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -8,6 +8,7 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/alarm_scheduler_service.dart'; import 'package:on_time_front/core/services/fallback_alarm_notification_service.dart'; import 'package:on_time_front/core/services/notification_service.dart'; +import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart'; import 'package:on_time_front/domain/entities/alarm_entities.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; @@ -244,11 +245,15 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { try { final alarmRepository = getIt.get(); final registryRepository = getIt.get(); + final schedulerService = getIt.get(); final fallbackService = getIt.get(); final settings = await alarmRepository.getAlarmSettings(); final records = await registryRepository.loadAll(); - final fallbackPermission = await fallbackService.checkPermission(); + final delivery = await _checkAlarmDeliveryPolicy( + schedulerService: schedulerService, + fallbackService: fallbackService, + ); if (!mounted) return; final l10n = AppLocalizations.of(context)!; @@ -258,7 +263,7 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { l10n: l10n, settings: settings, records: records, - fallbackPermission: fallbackPermission, + delivery: delivery.policy, ); _isLoading = false; }); @@ -275,7 +280,7 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { required AppLocalizations l10n, required AlarmSettings settings, required List records, - required AlarmPermissionState fallbackPermission, + required AlarmDeliveryPolicy delivery, }) { if (!settings.alarmsEnabled) return '꺼짐'; if (records.any((record) => record.provider == AlarmProvider.iosAlarmKit)) { @@ -291,7 +296,7 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { )) { return l10n.notificationStatus; } - if (fallbackPermission != AlarmPermissionState.granted) { + if (!delivery.canDeliver) { return l10n.notificationPermissionNeededStatus; } return l10n.noScheduledNotificationStatus; @@ -308,9 +313,13 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { final schedulerService = getIt.get(); final fallbackService = getIt.get(); - final nativePermission = await schedulerService.checkPermission(); + var delivery = await _checkAlarmDeliveryPolicy( + schedulerService: schedulerService, + fallbackService: fallbackService, + ); if (!mounted) return; - if (_needsExactAlarmRecovery(nativePermission)) { + if (delivery.policy.blockingPermissionIssue == + AlarmPermissionIssue.nativePermissionDenied) { final shouldOpenSettings = await _showExactAlarmPermissionDialog( context, ); @@ -318,9 +327,12 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { if (shouldOpenSettings == DialogActionResult.primary) { await schedulerService.requestPermission(); } - final updatedNativePermission = await schedulerService - .checkPermission(); - if (_needsExactAlarmRecovery(updatedNativePermission)) { + delivery = await _checkAlarmDeliveryPolicy( + schedulerService: schedulerService, + fallbackService: fallbackService, + ); + if (!delivery.policy.canDeliver && + delivery.policy.shouldDisableAlarms) { await alarmRepository.updateAlarmSettings(alarmsEnabled: false); await getIt.get()(); await _load(); @@ -328,7 +340,24 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { } } - await fallbackService.requestPermission(); + final fallbackPermission = await fallbackService.requestPermission(); + delivery = _AlarmDeliveryDecision( + policy: AlarmDeliveryPolicy.evaluate( + capabilities: delivery.capabilities, + nativePermission: delivery.nativePermission, + fallbackPermission: fallbackPermission, + ), + capabilities: delivery.capabilities, + nativePermission: delivery.nativePermission, + fallbackPermission: fallbackPermission, + ); + if (!delivery.policy.canDeliver && + delivery.policy.shouldDisableAlarms) { + await alarmRepository.updateAlarmSettings(alarmsEnabled: false); + await getIt.get()(); + await _load(); + return; + } await alarmRepository.updateAlarmSettings(alarmsEnabled: true); await getIt.get()(); } else { @@ -376,9 +405,64 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { } } -bool _needsExactAlarmRecovery(AlarmPermissionState permission) { - return permission == AlarmPermissionState.denied || - permission == AlarmPermissionState.notDetermined; +class _AlarmDeliveryDecision { + const _AlarmDeliveryDecision({ + required this.policy, + required this.capabilities, + required this.nativePermission, + required this.fallbackPermission, + }); + + final AlarmDeliveryPolicy policy; + final AlarmSchedulerCapabilities capabilities; + final AlarmPermissionState nativePermission; + final AlarmPermissionState fallbackPermission; +} + +Future<_AlarmDeliveryDecision> _checkAlarmDeliveryPolicy({ + required AlarmSchedulerService schedulerService, + required FallbackAlarmNotificationService fallbackService, +}) async { + final capabilities = await schedulerService.getCapabilities(); + final nativePermission = await _checkNativePermission( + schedulerService, + capabilities, + ); + final fallbackPermission = await _checkFallbackPermission( + fallbackService, + capabilities, + ); + return _AlarmDeliveryDecision( + policy: AlarmDeliveryPolicy.evaluate( + capabilities: capabilities, + nativePermission: nativePermission, + fallbackPermission: fallbackPermission, + ), + capabilities: capabilities, + nativePermission: nativePermission, + fallbackPermission: fallbackPermission, + ); +} + +Future _checkNativePermission( + AlarmSchedulerService schedulerService, + AlarmSchedulerCapabilities capabilities, +) async { + if (!capabilities.supportsNativeAlarm || + capabilities.nativeAlarmProvider == AlarmProvider.none) { + return AlarmPermissionState.unsupported; + } + return schedulerService.checkPermission(); +} + +Future _checkFallbackPermission( + FallbackAlarmNotificationService fallbackService, + AlarmSchedulerCapabilities capabilities, +) async { + if (capabilities.fallbackProvider != AlarmProvider.localNotification) { + return AlarmPermissionState.unsupported; + } + return fallbackService.checkPermission(); } class _MyAccountView extends StatelessWidget { diff --git a/test/domain/entities/alarm_delivery_policy_test.dart b/test/domain/entities/alarm_delivery_policy_test.dart new file mode 100644 index 00000000..4833edfd --- /dev/null +++ b/test/domain/entities/alarm_delivery_policy_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/alarm_delivery_policy.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; + +void main() { + test('native grant uses the native alarm provider', () { + final decision = AlarmDeliveryPolicy.evaluate( + capabilities: const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ), + nativePermission: AlarmPermissionState.granted, + fallbackPermission: AlarmPermissionState.denied, + ); + + expect(decision.mode, AlarmDeliveryMode.nativeAlarm); + expect(decision.activeProvider, AlarmProvider.androidAlarmManager); + expect(decision.canDeliver, isTrue); + expect(decision.blockingPermissionIssue, isNull); + expect(decision.recoveryPermissionIssue, isNull); + expect(decision.shouldDisableAlarms, isFalse); + }); + + test( + 'native denial with granted fallback keeps delivery enabled without a blocking issue', + () { + final decision = AlarmDeliveryPolicy.evaluate( + capabilities: const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ), + nativePermission: AlarmPermissionState.denied, + fallbackPermission: AlarmPermissionState.granted, + ); + + expect(decision.mode, AlarmDeliveryMode.fallbackNotification); + expect(decision.canDeliver, isTrue); + expect(decision.blockingPermissionIssue, isNull); + expect( + decision.recoveryPermissionIssue, + AlarmPermissionIssue.nativePermissionDenied, + ); + expect(decision.shouldDisableAlarms, isFalse); + }, + ); + + test( + 'native denial with denied fallback blocks delivery on native permission', + () { + final decision = AlarmDeliveryPolicy.evaluate( + capabilities: const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ), + nativePermission: AlarmPermissionState.denied, + fallbackPermission: AlarmPermissionState.denied, + ); + + expect(decision.mode, AlarmDeliveryMode.unavailable); + expect(decision.canDeliver, isFalse); + expect( + decision.blockingPermissionIssue, + AlarmPermissionIssue.nativePermissionDenied, + ); + expect(decision.shouldDisableAlarms, isTrue); + }, + ); + + test('unsupported native alarms use granted fallback delivery', () { + final decision = AlarmDeliveryPolicy.evaluate( + capabilities: const AlarmSchedulerCapabilities( + supportsNativeAlarm: false, + nativeAlarmProvider: AlarmProvider.none, + fallbackProvider: AlarmProvider.localNotification, + ), + nativePermission: AlarmPermissionState.unsupported, + fallbackPermission: AlarmPermissionState.granted, + ); + + expect(decision.mode, AlarmDeliveryMode.fallbackNotification); + expect(decision.activeProvider, AlarmProvider.localNotification); + expect(decision.blockingPermissionIssue, isNull); + expect(decision.recoveryPermissionIssue, isNull); + expect(decision.shouldDisableAlarms, isFalse); + }); + + test('all unavailable reports unsupported without disabling alarms', () { + final decision = AlarmDeliveryPolicy.evaluate( + capabilities: AlarmSchedulerCapabilities.unsupported, + nativePermission: AlarmPermissionState.unsupported, + fallbackPermission: AlarmPermissionState.unsupported, + ); + + expect(decision.mode, AlarmDeliveryMode.unavailable); + expect(decision.canDeliver, isFalse); + expect(decision.isUnsupported, isTrue); + expect(decision.blockingPermissionIssue, isNull); + expect(decision.shouldDisableAlarms, isFalse); + }); +} diff --git a/test/presentation/app/cubit/alarm_gate_cubit_test.dart b/test/presentation/app/cubit/alarm_gate_cubit_test.dart index 28f1a536..ae3e0c49 100644 --- a/test/presentation/app/cubit/alarm_gate_cubit_test.dart +++ b/test/presentation/app/cubit/alarm_gate_cubit_test.dart @@ -185,7 +185,7 @@ void main() { ); test( - 'refreshPermission still prompts for exact timing but keeps fallback delivery', + 'refreshPermission allows fallback delivery when exact timing is denied', () async { final repository = _FakeAlarmRepository(); final cancelAll = _FakeCancelAllAlarmsUseCase(); @@ -208,7 +208,8 @@ void main() { await cubit.refreshPermission(disableAlarmsWhenPermissionMissing: true); - expect(cubit.state, const AlarmGateState.required()); + expect(cubit.state, const AlarmGateState.allowed()); + expect(cubit.state.shouldPrompt, isFalse); expect(repository.updatedAlarmSettings, isEmpty); expect(cancelAll.callCount, 0); }, diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index 935bcc2d..7fa42f73 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -273,15 +273,18 @@ void main() { expect(notificationService.openSettingsCount, 1); }); - testWidgets('keeps alarms disabled when exact alarm permission is missing', ( + testWidgets('enables alarms through fallback when exact timing is denied', ( tester, ) async { final alarmRepository = getIt.get() as _FakeAlarmRepository; final alarmScheduler = getIt.get() as _FakeAlarmSchedulerService; - final cancelAllUseCase = - getIt.get() as _FakeCancelAllAlarmsUseCase; + final fallbackService = + getIt.get() + as _FakeFallbackAlarmNotificationService; + final reconcileUseCase = + getIt.get() as _FakeReconcileAlarmsUseCase; alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); alarmScheduler ..capabilities = const AlarmSchedulerCapabilities( @@ -289,19 +292,20 @@ void main() { nativeAlarmProvider: AlarmProvider.androidAlarmManager, ) ..permission = AlarmPermissionState.denied; + fallbackService.permission = AlarmPermissionState.granted; await _pumpMyPage(tester, locale: const Locale('en')); await tester.tap(find.byKey(const Key('alarmSettingsSwitch'))); await tester.pumpAndSettle(); - await tester.tap(find.text("I'll do it later.")); - await tester.pumpAndSettle(); - expect(alarmRepository.updatedSettings, [false]); - expect(cancelAllUseCase.callCount, 1); + expect(find.text('Precise notification permission needed'), findsNothing); + expect(alarmRepository.updatedSettings, [true]); + expect(fallbackService.requestCount, 1); + expect(reconcileUseCase.callCount, 1); expect( tester.widget(find.byKey(const Key('alarmSettingsSwitch'))).value, - isFalse, + isTrue, ); }); @@ -325,7 +329,7 @@ void main() { ) ..permission = AlarmPermissionState.denied ..permissionAfterRequest = AlarmPermissionState.granted; - fallbackService.permission = AlarmPermissionState.granted; + fallbackService.permission = AlarmPermissionState.denied; await _pumpMyPage(tester, locale: const Locale('en'));