From 5ac93ec44b9df0f477556cc7dd05dab1b401cc62 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Mon, 11 May 2026 14:30:43 +0900 Subject: [PATCH 1/4] fix: add alarm permission gate flow --- lib/l10n/app_en.arb | 12 + lib/l10n/app_ko.arb | 3 + lib/l10n/app_localizations.dart | 18 + lib/l10n/app_localizations_en.dart | 10 + lib/l10n/app_localizations_ko.dart | 10 + .../screens/alarm_allow_screen.dart | 200 +++++++++++ .../app/cubit/alarm_gate_cubit.dart | 117 +++++++ .../app/cubit/alarm_gate_state.dart | 27 ++ lib/presentation/app/screens/app.dart | 32 +- lib/presentation/my_page/my_page_screen.dart | 8 + lib/presentation/shared/router/go_router.dart | 95 ++--- .../alarm_allow/alarm_allow_screen_test.dart | 327 ++++++++++++++++++ .../my_page/my_page_screen_test.dart | 142 +++++++- 13 files changed, 946 insertions(+), 55 deletions(-) create mode 100644 lib/presentation/alarm_allow/screens/alarm_allow_screen.dart create mode 100644 lib/presentation/app/cubit/alarm_gate_cubit.dart create mode 100644 lib/presentation/app/cubit/alarm_gate_state.dart create mode 100644 test/presentation/alarm_allow/alarm_allow_screen_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 54ec6f45..8786969d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -51,6 +51,10 @@ "@allowNotifications": { "description": "Button text to allow notifications" }, + "allowAlarms": "Allow alarms", + "@allowAlarms": { + "description": "Button text to allow alarm permission" + }, "doItLater": "I'll do it later.", "@doItLater": { "description": "Button text to skip a step and do it later" @@ -63,6 +67,14 @@ "@notificationPermissionDescription": { "description": "Description explaining why notification permission is needed" }, + "pleaseAllowAlarms": "Please allow alarms", + "@pleaseAllowAlarms": { + "description": "Title asking the user to allow alarm permission" + }, + "alarmPermissionDescription": "OnTime uses alarms so preparation starts at the right moment, even when the app is closed.", + "@alarmPermissionDescription": { + "description": "Description explaining why alarm permission is needed" + }, "late": " late", "@late": { "description": "Appended to the time when the user is late" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 577ccbf5..d6dcb735 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -12,9 +12,12 @@ "am": "오전", "pm": "오후", "allowNotifications": "알림 허용하기", + "allowAlarms": "알람 허용하기", "doItLater": "나중에 할게요.", "pleaseAllowNotifications": "알림을 허용해주세요", "notificationPermissionDescription": "약속 준비 리마인더를 보내\n제시간에 준비할 수 있게 도와드려요.", + "pleaseAllowAlarms": "알람을 허용해주세요", + "alarmPermissionDescription": "앱이 닫혀 있어도 약속 준비를 제시간에 시작할 수 있도록 알람을 사용해요.", "late": " 지각했어요", "early": " 일찍 준비했어요", "letsGo": "까먹지 않고 출발", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9062afb6..02f67e8e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -176,6 +176,12 @@ abstract class AppLocalizations { /// **'Allow notifications'** String get allowNotifications; + /// Button text to allow alarm permission + /// + /// In en, this message translates to: + /// **'Allow alarms'** + String get allowAlarms; + /// Button text to skip a step and do it later /// /// In en, this message translates to: @@ -194,6 +200,18 @@ abstract class AppLocalizations { /// **'OnTime sends schedule preparation reminders so you can get ready on time.'** String get notificationPermissionDescription; + /// Title asking the user to allow alarm permission + /// + /// In en, this message translates to: + /// **'Please allow alarms'** + String get pleaseAllowAlarms; + + /// Description explaining why alarm permission is needed + /// + /// In en, this message translates to: + /// **'OnTime uses alarms so preparation starts at the right moment, even when the app is closed.'** + String get alarmPermissionDescription; + /// Appended to the time when the user is late /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index cbb79348..74224485 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -49,6 +49,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allowNotifications => 'Allow notifications'; + @override + String get allowAlarms => 'Allow alarms'; + @override String get doItLater => 'I\'ll do it later.'; @@ -59,6 +62,13 @@ class AppLocalizationsEn extends AppLocalizations { String get notificationPermissionDescription => 'OnTime sends schedule preparation reminders so you can get ready on time.'; + @override + String get pleaseAllowAlarms => 'Please allow alarms'; + + @override + String get alarmPermissionDescription => + 'OnTime uses alarms so preparation starts at the right moment, even when the app is closed.'; + @override String get late => ' late'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 657e4196..eb4e782b 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -47,6 +47,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get allowNotifications => '알림 허용하기'; + @override + String get allowAlarms => '알람 허용하기'; + @override String get doItLater => '나중에 할게요.'; @@ -57,6 +60,13 @@ class AppLocalizationsKo extends AppLocalizations { String get notificationPermissionDescription => '약속 준비 리마인더를 보내\n제시간에 준비할 수 있게 도와드려요.'; + @override + String get pleaseAllowAlarms => '알람을 허용해주세요'; + + @override + String get alarmPermissionDescription => + '앱이 닫혀 있어도 약속 준비를 제시간에 시작할 수 있도록 알람을 사용해요.'; + @override String get late => ' 지각했어요'; diff --git a/lib/presentation/alarm_allow/screens/alarm_allow_screen.dart b/lib/presentation/alarm_allow/screens/alarm_allow_screen.dart new file mode 100644 index 00000000..2c38aa63 --- /dev/null +++ b/lib/presentation/alarm_allow/screens/alarm_allow_screen.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:on_time_front/domain/entities/alarm_entities.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/app/cubit/alarm_gate_cubit.dart'; +import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; + +class AlarmAllowScreen extends StatefulWidget { + const AlarmAllowScreen({super.key}); + + @override + State createState() => _AlarmAllowScreenState(); +} + +class _AlarmAllowScreenState extends State + with WidgetsBindingObserver { + bool _isRequesting = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + context.read().refreshPermission( + disableAlarmsWhenPermissionMissing: true, + enableAlarmsOnGrant: true, + ); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + Future _requestPermission() async { + setState(() { + _isRequesting = true; + }); + final permission = await context.read().requestPermission(); + if (!mounted) return; + setState(() { + _isRequesting = false; + }); + if (permission == AlarmPermissionState.granted || + permission == AlarmPermissionState.unsupported) { + context.go('/home'); + } + } + + Future _dismiss() async { + await context.read().dismissPrompt(); + if (!mounted) return; + context.go('/home'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.only(bottom: 72.0), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 68.50, + children: [ + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 40, + children: const [_Image(), _Title()], + ), + ), + ), + _Buttons( + isRequesting: _isRequesting, + onAllow: _requestPermission, + onDismiss: _dismiss, + ), + ], + ), + ), + ); + } +} + +class _Buttons extends StatelessWidget { + const _Buttons({ + required this.isRequesting, + required this.onAllow, + required this.onDismiss, + }); + + final bool isRequesting; + final Future Function() onAllow; + final Future Function() onDismiss; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 24, + children: [ + FilledButton( + onPressed: isRequesting ? null : () => onAllow(), + child: Text( + AppLocalizations.of(context)!.allowAlarms, + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith( + color: colorScheme.onPrimary, + ), + ), + ), + GestureDetector( + onTap: isRequesting ? null : () => onDismiss(), + child: SizedBox( + width: 358, + child: Text( + AppLocalizations.of(context)!.doItLater, + textAlign: TextAlign.center, + style: textTheme.bodyLarge?.copyWith( + color: AppColors.grey[400], + decoration: TextDecoration.underline, + decorationColor: AppColors.grey[400], + ), + ), + ), + ), + ], + ); + } +} + +class _Title extends StatelessWidget { + const _Title(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 12, + children: [ + Text( + AppLocalizations.of(context)!.pleaseAllowAlarms, + textAlign: TextAlign.center, + style: textTheme.headlineMedium?.copyWith(color: colorScheme.primary), + ), + SizedBox( + width: 282, + child: Text( + AppLocalizations.of(context)!.alarmPermissionDescription, + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith(color: colorScheme.outline), + ), + ), + ], + ); + } +} + +class _Image extends StatelessWidget { + const _Image(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: 70, + height: 70, + padding: const EdgeInsets.all(17.50), + decoration: ShapeDecoration( + color: colorScheme.primaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(35)), + ), + child: SvgPicture.asset( + 'bell-ringing.svg', + package: 'assets', + colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn), + ), + ); + } +} diff --git a/lib/presentation/app/cubit/alarm_gate_cubit.dart b/lib/presentation/app/cubit/alarm_gate_cubit.dart new file mode 100644 index 00000000..c021aa95 --- /dev/null +++ b/lib/presentation/app/cubit/alarm_gate_cubit.dart @@ -0,0 +1,117 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +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/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'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'alarm_gate_state.dart'; + +class AlarmGateCubit extends Cubit { + AlarmGateCubit({ + AlarmSchedulerService? alarmSchedulerService, + AlarmRepository? alarmRepository, + ReconcileAlarmsUseCase? reconcileAlarmsUseCase, + CancelAllAlarmsUseCase? cancelAllAlarmsUseCase, + }) : _alarmSchedulerService = + alarmSchedulerService ?? getIt.get(), + _alarmRepository = alarmRepository ?? getIt.get(), + _reconcileAlarmsUseCase = + reconcileAlarmsUseCase ?? getIt.get(), + _cancelAllAlarmsUseCase = + cancelAllAlarmsUseCase ?? getIt.get(), + super(const AlarmGateState.initial()); + + static const String _dismissedKey = 'alarm_prompt_dismissed'; + static const String _logTag = '[AlarmGate]'; + + final AlarmSchedulerService _alarmSchedulerService; + final AlarmRepository _alarmRepository; + final ReconcileAlarmsUseCase _reconcileAlarmsUseCase; + final CancelAllAlarmsUseCase _cancelAllAlarmsUseCase; + + Future refreshPermission({ + bool disableAlarmsWhenPermissionMissing = false, + bool enableAlarmsOnGrant = false, + }) async { + final permission = await _alarmSchedulerService.checkPermission(); + if (permission == AlarmPermissionState.unsupported) { + emit(const AlarmGateState.unsupported()); + return; + } + + final prefs = await SharedPreferences.getInstance(); + if (permission == AlarmPermissionState.granted) { + await prefs.remove(_dismissedKey); + if (enableAlarmsOnGrant) { + await _enableAlarmsBestEffort(); + } + emit(const AlarmGateState.allowed()); + return; + } + + if (disableAlarmsWhenPermissionMissing) { + await _disableAlarmsBestEffort(); + } + + final isDismissed = prefs.getBool(_dismissedKey) ?? false; + emit( + isDismissed + ? const AlarmGateState.dismissed() + : const AlarmGateState.required(), + ); + } + + Future requestPermission() async { + final permission = await _alarmSchedulerService.requestPermission(); + if (permission == AlarmPermissionState.unsupported) { + emit(const AlarmGateState.unsupported()); + return permission; + } + + if (permission == AlarmPermissionState.granted) { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_dismissedKey); + await _enableAlarmsBestEffort(); + emit(const AlarmGateState.allowed()); + return permission; + } + + await _disableAlarmsBestEffort(); + emit(const AlarmGateState.required()); + return permission; + } + + Future dismissPrompt() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_dismissedKey, true); + await _disableAlarmsBestEffort(); + emit(const AlarmGateState.dismissed()); + } + + Future _enableAlarmsBestEffort() async { + try { + await _alarmRepository.updateAlarmSettings(alarmsEnabled: true); + await _reconcileAlarmsUseCase(); + } catch (error) { + AppLogger.debug( + '$_logTag enable alarms failed errorType=${error.runtimeType}', + ); + } + } + + Future _disableAlarmsBestEffort() async { + try { + await _alarmRepository.updateAlarmSettings(alarmsEnabled: false); + await _cancelAllAlarmsUseCase(); + } catch (error) { + AppLogger.debug( + '$_logTag disable alarms failed errorType=${error.runtimeType}', + ); + } + } +} diff --git a/lib/presentation/app/cubit/alarm_gate_state.dart b/lib/presentation/app/cubit/alarm_gate_state.dart new file mode 100644 index 00000000..6e6e6e75 --- /dev/null +++ b/lib/presentation/app/cubit/alarm_gate_state.dart @@ -0,0 +1,27 @@ +part of 'alarm_gate_cubit.dart'; + +enum AlarmGateStatus { initial, allowed, required, dismissed, unsupported } + +class AlarmGateState extends Equatable { + const AlarmGateState._({required this.status}); + + const AlarmGateState.initial() : this._(status: AlarmGateStatus.initial); + + const AlarmGateState.allowed() : this._(status: AlarmGateStatus.allowed); + + const AlarmGateState.required() : this._(status: AlarmGateStatus.required); + + const AlarmGateState.dismissed() : this._(status: AlarmGateStatus.dismissed); + + const AlarmGateState.unsupported() + : this._(status: AlarmGateStatus.unsupported); + + final AlarmGateStatus status; + + bool get isResolved => status != AlarmGateStatus.initial; + + bool get shouldPrompt => status == AlarmGateStatus.required; + + @override + List get props => [status]; +} diff --git a/lib/presentation/app/screens/app.dart b/lib/presentation/app/screens/app.dart index e5a1daf6..d88848b3 100644 --- a/lib/presentation/app/screens/app.dart +++ b/lib/presentation/app/screens/app.dart @@ -10,6 +10,7 @@ import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/app/cubit/alarm_gate_cubit.dart'; import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; import 'package:on_time_front/presentation/shared/router/go_router.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; @@ -31,6 +32,7 @@ class App extends StatelessWidget { BlocProvider( create: (context) => NotificationGateCubit(), ), + BlocProvider(create: (context) => AlarmGateCubit()), ], child: const AppView(), ); @@ -61,6 +63,7 @@ class _AppRouterViewState extends State<_AppRouterView> context.read(), context.read(), context.read(), + context.read(), ); final _alarmLaunchPollTimers = []; Map? _pendingAlarmLaunchPayload; @@ -73,11 +76,18 @@ class _AppRouterViewState extends State<_AppRouterView> if (!mounted) return; AppLogger.debug('$_logTag initialize launch handling'); unawaited( - getIt - .get() - .initializeLaunchHandling(_handleAlarmLaunchPayload), + getIt.get().initializeLaunchHandling( + _handleAlarmLaunchPayload, + ), ); _schedulePendingAlarmLaunchPolls(); + if (context.read().state.status == AuthStatus.authenticated) { + unawaited( + context.read().refreshPermission( + disableAlarmsWhenPermissionMissing: true, + ), + ); + } }); } @@ -93,6 +103,11 @@ class _AppRouterViewState extends State<_AppRouterView> if (context.read().state.status != AuthStatus.authenticated) { return; } + unawaited( + context.read().refreshPermission( + disableAlarmsWhenPermissionMissing: true, + ), + ); unawaited(getIt.get()()); } @@ -170,7 +185,16 @@ class _AppRouterViewState extends State<_AppRouterView> Widget build(BuildContext context) { return BlocListener( listenWhen: (previous, current) => previous.status != current.status, - listener: (context, state) => _drainPendingAlarmLaunchPayload(), + listener: (context, state) { + _drainPendingAlarmLaunchPayload(); + if (state.status == AuthStatus.authenticated) { + unawaited( + context.read().refreshPermission( + disableAlarmsWhenPermissionMissing: true, + ), + ); + } + }, child: MaterialApp.router( theme: themeData, routerConfig: _router, diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 62a86396..bca70d43 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -257,6 +257,14 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { if (shouldOpenSettings == DialogActionResult.primary) { await schedulerService.requestPermission(); } + final updatedNativePermission = await schedulerService + .checkPermission(); + if (_needsExactAlarmRecovery(updatedNativePermission)) { + await alarmRepository.updateAlarmSettings(alarmsEnabled: false); + await getIt.get()(); + await _load(); + return; + } } await fallbackService.requestPermission(); diff --git a/lib/presentation/shared/router/go_router.dart b/lib/presentation/shared/router/go_router.dart index e0b0828d..0f3d763e 100644 --- a/lib/presentation/shared/router/go_router.dart +++ b/lib/presentation/shared/router/go_router.dart @@ -5,8 +5,10 @@ import 'package:on_time_front/core/di/di_setup.dart'; import 'package:on_time_front/core/services/navigation_service.dart'; import 'package:on_time_front/presentation/alarm/screens/alarm_screen.dart'; import 'package:on_time_front/presentation/alarm/screens/schedule_start_screen.dart'; +import 'package:on_time_front/presentation/alarm_allow/screens/alarm_allow_screen.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/app/bloc/schedule/schedule_bloc.dart'; +import 'package:on_time_front/presentation/app/cubit/alarm_gate_cubit.dart'; import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; import 'package:on_time_front/presentation/early_late/screens/early_late_screen.dart'; import 'package:on_time_front/presentation/calendar/screens/calendar_screen.dart'; @@ -33,25 +35,32 @@ GoRouter goRouterConfig( AuthBloc authBloc, ScheduleBloc scheduleBloc, NotificationGateCubit notificationGateCubit, + AlarmGateCubit alarmGateCubit, ) { return GoRouter( refreshListenable: StreamToListenable([ scheduleBloc.stream, authBloc.stream, notificationGateCubit.stream, + alarmGateCubit.stream, ]), navigatorKey: getIt.get().navigatorKey, redirect: (BuildContext context, GoRouterState state) { final authStatus = authBloc.state.status; final notificationGateStatus = notificationGateCubit.state.status; + final alarmGateStatus = alarmGateCubit.state.status; final path = state.uri.path; final isStartupRoute = path == '/startup'; final isPublicRoute = isStartupRoute || path == '/signIn'; final isOnboardingRoute = path == '/onboarding' || path == '/onboarding/start'; final isNotificationRoute = path == '/allowNotification'; + final isAlarmRoute = path == '/allowAlarm'; final isTransientRoute = - isPublicRoute || isOnboardingRoute || isNotificationRoute; + isPublicRoute || + isOnboardingRoute || + isNotificationRoute || + isAlarmRoute; switch (authStatus) { case AuthStatus.loading: @@ -59,12 +68,16 @@ GoRouter goRouterConfig( case AuthStatus.unauthenticated: return path == '/signIn' ? null : '/signIn'; case AuthStatus.authenticated: - if (!notificationGateCubit.state.isResolved) { + if (!notificationGateCubit.state.isResolved || + !alarmGateCubit.state.isResolved) { return isStartupRoute ? null : '/startup'; } if (notificationGateStatus == NotificationGateStatus.required) { return isNotificationRoute ? null : '/allowNotification'; } + if (alarmGateStatus == AlarmGateStatus.required) { + return isAlarmRoute ? null : '/allowAlarm'; + } return isTransientRoute ? '/home' : null; case AuthStatus.onboardingNotCompleted: return isOnboardingRoute ? null : '/onboarding/start'; @@ -82,6 +95,10 @@ GoRouter goRouterConfig( return NotificationAllowScreen(); }, ), + GoRoute( + path: '/allowAlarm', + builder: (context, state) => const AlarmAllowScreen(), + ), GoRoute( path: '/onboarding', builder: (context, state) => OnboardingScreen(), @@ -89,20 +106,14 @@ GoRouter goRouterConfig( GoRoute( path: '/start', builder: (context, state) => OnboardingStartScreen(), - ) + ), ], ), ShellRoute( builder: (context, state, child) => BottomNavBarScaffold(child: child), routes: [ - GoRoute( - path: '/home', - builder: (context, state) => HomeScreenTmp(), - ), - GoRoute( - path: '/myPage', - builder: (context, state) => MyPageScreen(), - ), + GoRoute(path: '/home', builder: (context, state) => HomeScreenTmp()), + GoRoute(path: '/myPage', builder: (context, state) => MyPageScreen()), ], ), GoRoute( @@ -112,20 +123,22 @@ GoRouter goRouterConfig( GoRoute(path: '/signIn', builder: (context, state) => SignInMainScreen()), GoRoute( path: '/calendar', - builder: (context, state) => CalendarScreen( - initialDate: calendarInitialDateFromState(state), - ), + builder: (context, state) => + CalendarScreen(initialDate: calendarInitialDateFromState(state)), ), GoRoute( - path: '/scheduleCreate', - builder: (context, state) => ScheduleCreateScreen()), + path: '/scheduleCreate', + builder: (context, state) => ScheduleCreateScreen(), + ), GoRoute( - path: '/scheduleEdit/:scheduleId', - builder: (context, state) => ScheduleEditScreen( - scheduleId: state.pathParameters['scheduleId']!)), + path: '/scheduleEdit/:scheduleId', + builder: (context, state) => + ScheduleEditScreen(scheduleId: state.pathParameters['scheduleId']!), + ), GoRoute( - path: '/preparationEdit', - builder: (context, state) => const PreparationEditForm()), + path: '/preparationEdit', + builder: (context, state) => const PreparationEditForm(), + ), GoRoute( path: '/scheduleStart', name: 'scheduleStart', @@ -159,10 +172,7 @@ GoRouter goRouterConfig( ); }, ), - GoRoute( - path: '/moving', - builder: (context, state) => MovingScreen(), - ), + GoRoute(path: '/moving', builder: (context, state) => MovingScreen()), ], ); } @@ -188,16 +198,16 @@ class _ScheduleStartRouteGateState extends State<_ScheduleStartRouteGate> { if (scheduleId == null || scheduleId.isEmpty) return; _requestedValidation = true; context.read().add( - ScheduleAlarmPromptRequested( - scheduleId: scheduleId, - scheduleFingerprint: - routeStringValue(widget.extra?['scheduleFingerprint']), - startPreparation: scheduleStartLaunchActionFromRouteExtra( - widget.extra, - ) == - ScheduleStartLaunchAction.startPreparation, - ), - ); + ScheduleAlarmPromptRequested( + scheduleId: scheduleId, + scheduleFingerprint: routeStringValue( + widget.extra?['scheduleFingerprint'], + ), + startPreparation: + scheduleStartLaunchActionFromRouteExtra(widget.extra) == + ScheduleStartLaunchAction.startPreparation, + ), + ); } @override @@ -208,11 +218,11 @@ class _ScheduleStartRouteGateState extends State<_ScheduleStartRouteGate> { } final scheduleId = routeStringValue(widget.extra?['scheduleId']); - final scheduleFingerprint = - routeStringValue(widget.extra?['scheduleFingerprint']); - final allowsStaleFingerprint = scheduleStartLaunchActionFromRouteExtra( - widget.extra, - ) == + final scheduleFingerprint = routeStringValue( + widget.extra?['scheduleFingerprint'], + ); + final allowsStaleFingerprint = + scheduleStartLaunchActionFromRouteExtra(widget.extra) == ScheduleStartLaunchAction.startPreparation; final schedule = scheduleState.schedule; if (schedule == null) { @@ -227,8 +237,9 @@ class _ScheduleStartRouteGateState extends State<_ScheduleStartRouteGate> { return const LoadingScreen(); } - final promptVariant = - scheduleStartPromptVariantFromRouteExtra(widget.extra); + final promptVariant = scheduleStartPromptVariantFromRouteExtra( + widget.extra, + ); return ScheduleStartScreen(promptVariant: promptVariant); } } diff --git a/test/presentation/alarm_allow/alarm_allow_screen_test.dart b/test/presentation/alarm_allow/alarm_allow_screen_test.dart new file mode 100644 index 00000000..348a7603 --- /dev/null +++ b/test/presentation/alarm_allow/alarm_allow_screen_test.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.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_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'; +import 'package:on_time_front/domain/repositories/alarm_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; +import 'package:on_time_front/l10n/app_localizations.dart'; +import 'package:on_time_front/presentation/alarm_allow/screens/alarm_allow_screen.dart'; +import 'package:on_time_front/presentation/app/cubit/alarm_gate_cubit.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('shows English alarm permission rationale', (tester) async { + final harness = await _pumpAlarmAllowScreen( + tester, + locale: const Locale('en'), + permissionAfterRequest: AlarmPermissionState.denied, + ); + addTearDown(harness.dispose); + + expect( + find.text( + 'OnTime uses alarms so preparation starts at the right moment, even when the app is closed.', + ), + findsOneWidget, + ); + }); + + testWidgets('granted alarm permission enables alarms and continues home', ( + tester, + ) async { + final harness = await _pumpAlarmAllowScreen( + tester, + permissionAfterRequest: AlarmPermissionState.granted, + ); + addTearDown(harness.dispose); + + await tester.tap(find.text('Allow alarms')); + await tester.pumpAndSettle(); + + expect(harness.scheduler.requestCount, 1); + expect(harness.repository.updatedSettings, [true]); + expect(harness.reconcileUseCase.callCount, 1); + expect(find.text('home'), findsOneWidget); + expect(harness.gateCubit.state.status, AlarmGateStatus.allowed); + }); + + testWidgets('dismiss disables alarms and continues home', (tester) async { + final harness = await _pumpAlarmAllowScreen( + tester, + permissionAfterRequest: AlarmPermissionState.denied, + ); + addTearDown(harness.dispose); + + await tester.tap(find.text("I'll do it later.")); + await tester.pumpAndSettle(); + + expect(harness.repository.updatedSettings, [false]); + expect(harness.cancelAllUseCase.callCount, 1); + expect(find.text('home'), findsOneWidget); + expect(harness.gateCubit.state.status, AlarmGateStatus.dismissed); + }); +} + +Future<_AlarmAllowHarness> _pumpAlarmAllowScreen( + WidgetTester tester, { + Locale locale = const Locale('en'), + required AlarmPermissionState permissionAfterRequest, +}) async { + final repository = _FakeAlarmRepository(); + final registry = _FakeAlarmRegistry(); + final scheduler = _FakeAlarmSchedulerService( + permissionAfterRequest: permissionAfterRequest, + ); + final fallback = _FakeFallbackAlarmNotificationService(); + final reconcileUseCase = _FakeReconcileAlarmsUseCase( + repository, + registry, + scheduler, + fallback, + ); + final cancelAllUseCase = _FakeCancelAllAlarmsUseCase( + repository, + registry, + scheduler, + fallback, + ); + final gateCubit = AlarmGateCubit( + alarmSchedulerService: scheduler, + alarmRepository: repository, + reconcileAlarmsUseCase: reconcileUseCase, + cancelAllAlarmsUseCase: cancelAllUseCase, + ); + final router = GoRouter( + initialLocation: '/allowAlarm', + routes: [ + GoRoute( + path: '/allowAlarm', + builder: (context, state) => BlocProvider.value( + value: gateCubit, + child: const AlarmAllowScreen(), + ), + ), + GoRoute( + path: '/home', + builder: (context, state) => const Scaffold(body: Text('home')), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router( + theme: themeData, + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + await tester.pumpAndSettle(); + + return _AlarmAllowHarness( + gateCubit: gateCubit, + router: router, + repository: repository, + scheduler: scheduler, + reconcileUseCase: reconcileUseCase, + cancelAllUseCase: cancelAllUseCase, + ); +} + +class _AlarmAllowHarness { + _AlarmAllowHarness({ + required this.gateCubit, + required this.router, + required this.repository, + required this.scheduler, + required this.reconcileUseCase, + required this.cancelAllUseCase, + }); + + final AlarmGateCubit gateCubit; + final GoRouter router; + final _FakeAlarmRepository repository; + final _FakeAlarmSchedulerService scheduler; + final _FakeReconcileAlarmsUseCase reconcileUseCase; + final _FakeCancelAllAlarmsUseCase cancelAllUseCase; + + void dispose() { + gateCubit.close(); + router.dispose(); + } +} + +class _FakeAlarmSchedulerService extends AlarmSchedulerService { + _FakeAlarmSchedulerService({required this.permissionAfterRequest}); + + AlarmPermissionState permissionAfterRequest; + int requestCount = 0; + + @override + Future checkPermission() async { + return permissionAfterRequest; + } + + @override + Future requestPermission() async { + requestCount += 1; + return permissionAfterRequest; + } +} + +class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { + // ignore: use_super_parameters + _FakeReconcileAlarmsUseCase( + AlarmRepository alarmRepository, + AlarmRegistryRepository registryRepository, + AlarmSchedulerService schedulerService, + FallbackAlarmNotificationService fallbackNotificationService, + ) : super.test( + alarmRepository, + registryRepository, + schedulerService, + fallbackNotificationService, + nowProvider: () => DateTime(2026), + ); + + int callCount = 0; + + @override + Future call() async { + callCount += 1; + return AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: DateTime(2026), + scheduleWindowEnd: DateTime(2026), + alarmCoverageStart: DateTime(2026), + alarmCoverageEnd: DateTime(2026), + ); + } +} + +class _FakeCancelAllAlarmsUseCase extends CancelAllAlarmsUseCase { + // ignore: use_super_parameters + _FakeCancelAllAlarmsUseCase( + AlarmRepository alarmRepository, + AlarmRegistryRepository registryRepository, + AlarmSchedulerService schedulerService, + FallbackAlarmNotificationService fallbackNotificationService, + ) : super( + alarmRepository, + registryRepository, + schedulerService, + fallbackNotificationService, + ); + + int callCount = 0; + + @override + Future call({bool unregisterDevice = false}) async { + callCount += 1; + } +} + +class _FakeAlarmRepository implements AlarmRepository { + final updatedSettings = []; + + @override + Future getAlarmSettings() async { + return const AlarmSettings(alarmsEnabled: true); + } + + @override + Future updateAlarmSettings({ + required bool alarmsEnabled, + }) async { + updatedSettings.add(alarmsEnabled); + return AlarmSettings(alarmsEnabled: alarmsEnabled); + } + + @override + Future getDeviceId() async => 'device-id'; + + @override + Future buildCurrentDeviceInfo() async { + return const AlarmDeviceInfo( + deviceId: 'device-id', + platform: 'test', + appVersion: '1.0.0', + osVersion: 'test', + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + ); + } + + @override + Future> getAlarmWindow( + DateTime startDate, + DateTime endDate, + ) async { + return const []; + } + + @override + Future postAlarmStatus(AlarmStatusReport report) async {} + + @override + Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) async {} + + @override + Future unregisterCurrentDevice(String deviceId) async {} +} + +class _FakeAlarmRegistry implements AlarmRegistryRepository { + @override + Future> loadAll() async => const []; + + @override + Future upsert(ScheduledAlarmRecord record) async {} + + @override + Future deleteByScheduleId(String scheduleId) async {} + + @override + Future deleteAll() async {} + + @override + Future replaceAll(List records) async {} +} + +class _FakeFallbackAlarmNotificationService + implements FallbackAlarmNotificationService { + @override + Future checkPermission() async { + return AlarmPermissionState.granted; + } + + @override + Future requestPermission() async { + return AlarmPermissionState.granted; + } + + @override + Future scheduleFallbackAlarm(ScheduledAlarmRecord record) async {} + + @override + Future cancelFallbackAlarm(ScheduledAlarmRecord record) async {} +} diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index e18b736d..9df16783 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -10,6 +10,8 @@ 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'; import 'package:on_time_front/domain/repositories/alarm_repository.dart'; +import 'package:on_time_front/domain/use-cases/cancel_all_alarms_use_case.dart'; +import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/my_page/my_page_screen.dart'; @@ -20,12 +22,33 @@ void main() { setUp(() async { await getIt.reset(); + final alarmRepository = _FakeAlarmRepository(); + final alarmRegistry = _FakeAlarmRegistry(); + final alarmScheduler = _FakeAlarmSchedulerService(); + final fallbackAlarmNotificationService = + _FakeFallbackAlarmNotificationService(); getIt - ..registerSingleton(_FakeAlarmRepository()) - ..registerSingleton(_FakeAlarmRegistry()) - ..registerSingleton(_FakeAlarmSchedulerService()) + ..registerSingleton(alarmRepository) + ..registerSingleton(alarmRegistry) + ..registerSingleton(alarmScheduler) ..registerSingleton( - _FakeFallbackAlarmNotificationService(), + fallbackAlarmNotificationService, + ) + ..registerSingleton( + _FakeCancelAllAlarmsUseCase( + alarmRepository, + alarmRegistry, + alarmScheduler, + fallbackAlarmNotificationService, + ), + ) + ..registerSingleton( + _FakeReconcileAlarmsUseCase( + alarmRepository, + alarmRegistry, + alarmScheduler, + fallbackAlarmNotificationService, + ), ); }); @@ -63,6 +86,35 @@ void main() { expect(openedUris, [ExternalLinks.privacyPolicyUri]); }); + + testWidgets('keeps alarms disabled when exact alarm permission is missing', ( + tester, + ) async { + final alarmRepository = + getIt.get() as _FakeAlarmRepository; + final alarmScheduler = + getIt.get() as _FakeAlarmSchedulerService; + final cancelAllUseCase = + getIt.get() as _FakeCancelAllAlarmsUseCase; + alarmRepository.settings = const AlarmSettings(alarmsEnabled: false); + alarmScheduler + ..capabilities = const AlarmSchedulerCapabilities( + supportsNativeAlarm: true, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + ) + ..permission = AlarmPermissionState.denied; + + await _pumpMyPage(tester, locale: const Locale('en')); + + await tester.tap(find.byType(Switch)); + 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(tester.widget(find.byType(Switch)).value, isFalse); + }); } Future _pumpMyPage( @@ -97,6 +149,9 @@ class _StubAuthBloc extends Mock implements AuthBloc { } class _FakeAlarmRepository implements AlarmRepository { + AlarmSettings settings = const AlarmSettings(alarmsEnabled: false); + final updatedSettings = []; + @override Future getDeviceId() => throw UnimplementedError(); @@ -106,12 +161,16 @@ class _FakeAlarmRepository implements AlarmRepository { @override Future getAlarmSettings() async { - return const AlarmSettings(alarmsEnabled: false); + return settings; } @override - Future updateAlarmSettings({required bool alarmsEnabled}) { - throw UnimplementedError(); + Future updateAlarmSettings({ + required bool alarmsEnabled, + }) async { + updatedSettings.add(alarmsEnabled); + settings = AlarmSettings(alarmsEnabled: alarmsEnabled); + return settings; } @override @@ -164,14 +223,25 @@ class _FakeAlarmRegistry implements AlarmRegistryRepository { } class _FakeAlarmSchedulerService extends AlarmSchedulerService { + AlarmSchedulerCapabilities capabilities = + AlarmSchedulerCapabilities.unsupported; + AlarmPermissionState permission = AlarmPermissionState.unsupported; + int requestCount = 0; + @override Future getCapabilities() async { - return AlarmSchedulerCapabilities.unsupported; + return capabilities; } @override Future checkPermission() async { - return AlarmPermissionState.unsupported; + return permission; + } + + @override + Future requestPermission() async { + requestCount += 1; + return permission; } } @@ -197,3 +267,57 @@ class _FakeFallbackAlarmNotificationService throw UnimplementedError(); } } + +class _FakeCancelAllAlarmsUseCase extends CancelAllAlarmsUseCase { + // ignore: use_super_parameters + _FakeCancelAllAlarmsUseCase( + AlarmRepository alarmRepository, + AlarmRegistryRepository registryRepository, + AlarmSchedulerService schedulerService, + FallbackAlarmNotificationService fallbackNotificationService, + ) : super( + alarmRepository, + registryRepository, + schedulerService, + fallbackNotificationService, + ); + + int callCount = 0; + + @override + Future call({bool unregisterDevice = false}) async { + callCount += 1; + } +} + +class _FakeReconcileAlarmsUseCase extends ReconcileAlarmsUseCase { + // ignore: use_super_parameters + _FakeReconcileAlarmsUseCase( + AlarmRepository alarmRepository, + AlarmRegistryRepository registryRepository, + AlarmSchedulerService schedulerService, + FallbackAlarmNotificationService fallbackNotificationService, + ) : super.test( + alarmRepository, + registryRepository, + schedulerService, + fallbackNotificationService, + nowProvider: () => DateTime(2026), + ); + + @override + Future call() async { + return AlarmReconciliationResult( + status: AlarmReconciliationStatus.armed, + nativeAlarmProvider: AlarmProvider.androidAlarmManager, + fallbackProvider: AlarmProvider.localNotification, + armedScheduleIds: const [], + skippedScheduleCount: 0, + failures: const [], + scheduleWindowStart: DateTime(2026), + scheduleWindowEnd: DateTime(2026), + alarmCoverageStart: DateTime(2026), + alarmCoverageEnd: DateTime(2026), + ); + } +} From 3b108988799208840726f9fe85766d4ad5a78f3f Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Mon, 11 May 2026 14:32:24 +0900 Subject: [PATCH 2/4] fix: continue after notification settings grant --- .../screens/notification_allow_screen.dart | 41 ++++++++++++++++++- .../notification_allow_screen_test.dart | 29 +++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index b3c30f3f..217b6943 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -38,7 +38,7 @@ class NotificationServicePermissionGateway } } -class NotificationAllowScreen extends StatelessWidget { +class NotificationAllowScreen extends StatefulWidget { const NotificationAllowScreen({ super.key, this.permissionGateway = const NotificationServicePermissionGateway(), @@ -46,6 +46,43 @@ class NotificationAllowScreen extends StatelessWidget { final NotificationPermissionGateway permissionGateway; + @override + State createState() => + _NotificationAllowScreenState(); +} + +class _NotificationAllowScreenState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + _continueIfPermissionAllowed(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + Future _continueIfPermissionAllowed() async { + final currentStatus = await widget.permissionGateway + .checkNotificationPermission(); + if (!mounted || currentStatus != AuthorizationStatus.authorized) { + return; + } + + await context.read().markPermissionAllowed(); + if (!mounted) return; + context.go('/home'); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -68,7 +105,7 @@ class NotificationAllowScreen extends StatelessWidget { ), ), ), - _Buttons(permissionGateway: permissionGateway), + _Buttons(permissionGateway: widget.permissionGateway), ], ), ), diff --git a/test/presentation/notification_allow/notification_allow_screen_test.dart b/test/presentation/notification_allow/notification_allow_screen_test.dart index e0e9da66..91febee0 100644 --- a/test/presentation/notification_allow/notification_allow_screen_test.dart +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -122,6 +122,35 @@ void main() { expect(permissionGateway.openSettingsCount, 1); }); + testWidgets('settings grant automatically continues home on app resume', ( + tester, + ) async { + final gateService = _FakeNotificationService(); + final permissionGateway = _FakePermissionGateway( + currentStatus: AuthorizationStatus.denied, + ); + final harness = await _pumpNotificationAllowScreen( + tester, + permissionGateway: permissionGateway, + gateService: gateService, + ); + addTearDown(harness.dispose); + + await tester.tap(find.text('Allow notifications')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Settings')); + await tester.pumpAndSettle(); + + permissionGateway.currentStatus = AuthorizationStatus.authorized; + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + expect(find.text('home'), findsOneWidget); + expect(gateService.initializeCount, greaterThanOrEqualTo(1)); + expect(harness.gateCubit.state.status, NotificationGateStatus.allowed); + }); + testWidgets('request denial lets user dismiss prompt and continue home', ( tester, ) async { From 7c1c942d82aad31eff4147f412fe9089985f4798 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Mon, 11 May 2026 14:34:21 +0900 Subject: [PATCH 3/4] fix: avoid settings for notification permission --- .../screens/notification_allow_screen.dart | 54 +------------------ .../notification_allow_screen_test.dart | 31 +++-------- 2 files changed, 10 insertions(+), 75 deletions(-) diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index 217b6943..287e76f3 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -6,8 +6,6 @@ import 'package:go_router/go_router.dart'; import 'package:on_time_front/core/services/notification_service.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/cubit/notification_gate_cubit.dart'; -import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; -import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; import 'package:on_time_front/presentation/shared/constants/app_colors.dart'; abstract interface class NotificationPermissionGateway { @@ -231,17 +229,7 @@ Future _handleNotificationPermission( if (context.mounted) { context.go('/home'); } - } else if (currentStatus == AuthorizationStatus.denied) { - final shouldOpenSettings = await _showGoToSettingsDialog(context); - if (shouldOpenSettings == true) { - await permissionGateway.openNotificationSettings(); - } else if (context.mounted) { - await context.read().dismissPrompt(); - if (context.mounted) { - context.go('/home'); - } - } - } else if (currentStatus == AuthorizationStatus.notDetermined) { + } else { final newStatus = await permissionGateway.requestPermission(); if (!context.mounted) return; @@ -251,22 +239,7 @@ Future _handleNotificationPermission( if (context.mounted) { context.go('/home'); } - } else if (newStatus == AuthorizationStatus.denied) { - final shouldOpenSettings = await _showGoToSettingsDialog(context); - if (shouldOpenSettings == true) { - await permissionGateway.openNotificationSettings(); - } else if (context.mounted) { - await context.read().dismissPrompt(); - if (context.mounted) { - context.go('/home'); - } - } - } - } else { - final shouldOpenSettings = await _showGoToSettingsDialog(context); - if (shouldOpenSettings == true) { - await permissionGateway.openNotificationSettings(); - } else if (context.mounted) { + } else { await context.read().dismissPrompt(); if (context.mounted) { context.go('/home'); @@ -274,26 +247,3 @@ Future _handleNotificationPermission( } } } - -Future _showGoToSettingsDialog(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - - final result = await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.openNotificationSettings, - description: l10n.openNotificationSettingsDescription, - barrierDismissible: false, - secondaryAction: DialogActionConfig( - label: l10n.doItLater, - variant: ModalWideButtonVariant.neutral, - ), - primaryAction: DialogActionConfig( - label: l10n.openSettings, - variant: ModalWideButtonVariant.primary, - ), - ), - ); - - return result == DialogActionResult.primary; -} diff --git a/test/presentation/notification_allow/notification_allow_screen_test.dart b/test/presentation/notification_allow/notification_allow_screen_test.dart index 91febee0..ebde1868 100644 --- a/test/presentation/notification_allow/notification_allow_screen_test.dart +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -96,7 +96,9 @@ void main() { expect(harness.gateCubit.state.status, NotificationGateStatus.allowed); }); - testWidgets('denied permission opens settings recovery path', (tester) async { + testWidgets('denied permission retries request without opening settings', ( + tester, + ) async { final permissionGateway = _FakePermissionGateway( currentStatus: AuthorizationStatus.denied, ); @@ -109,20 +111,13 @@ void main() { await tester.tap(find.text('Allow notifications')); await tester.pumpAndSettle(); - expect( - find.text( - 'Notification permission was denied.\nTo receive schedule preparation reminders, please allow notifications in Settings.', - ), - findsOneWidget, - ); - - await tester.tap(find.text('Open Settings')); - await tester.pumpAndSettle(); - - expect(permissionGateway.openSettingsCount, 1); + expect(permissionGateway.requestCount, 1); + expect(permissionGateway.openSettingsCount, 0); + expect(find.text('home'), findsOneWidget); + expect(harness.gateCubit.state.status, NotificationGateStatus.dismissed); }); - testWidgets('settings grant automatically continues home on app resume', ( + testWidgets('manual settings grant still continues home on app resume', ( tester, ) async { final gateService = _FakeNotificationService(); @@ -136,11 +131,6 @@ void main() { ); addTearDown(harness.dispose); - await tester.tap(find.text('Allow notifications')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open Settings')); - await tester.pumpAndSettle(); - permissionGateway.currentStatus = AuthorizationStatus.authorized; tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); @@ -168,11 +158,6 @@ void main() { await tester.pumpAndSettle(); expect(permissionGateway.requestCount, 1); - expect(find.text('Allow Notifications in Settings'), findsOneWidget); - - await tester.tap(find.text("I'll do it later.").last); - await tester.pumpAndSettle(); - expect(find.text('home'), findsOneWidget); expect(harness.gateCubit.state.status, NotificationGateStatus.dismissed); }); From bba7d499b6ab0ef0d3fefa40ed98606dcca1f439 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Mon, 11 May 2026 18:21:44 +0900 Subject: [PATCH 4/4] feat: add notification toggle to my page --- lib/l10n/app_en.arb | 8 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 7 + lib/l10n/app_localizations_ko.dart | 7 + lib/presentation/my_page/my_page_screen.dart | 244 ++++++++++-------- .../screens/notification_allow_screen.dart | 7 + .../my_page/my_page_screen_test.dart | 122 ++++++++- .../notification_allow_screen_test.dart | 3 + 9 files changed, 297 insertions(+), 115 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8786969d..28aa2372 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -339,6 +339,14 @@ "@allowAppNotifications": { "description": "Setting tile for allowing app notifications" }, + "manageAppNotifications": "Manage App Notifications", + "@manageAppNotifications": { + "description": "Dialog title for opening app notification settings" + }, + "manageAppNotificationsDescription": "To turn app notifications on or off, update OnTime's notification permission in Settings.", + "@manageAppNotificationsDescription": { + "description": "Dialog description for opening app notification settings" + }, "privacyPolicy": "Privacy Policy", "@privacyPolicy": { "description": "Setting tile for opening the privacy policy" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d6dcb735..e3d6eb69 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -79,6 +79,8 @@ "accountSettings": "계정 설정", "editDefaultPreparation": "기본 준비과정 / 여유시간 수정", "allowAppNotifications": "앱 알림 허용", + "manageAppNotifications": "앱 알림 관리", + "manageAppNotificationsDescription": "앱 알림을 켜거나 끄려면 설정에서 온타임 알림 권한을 변경해주세요.", "privacyPolicy": "개인정보 처리방침", "privacyPolicyOpenError": "개인정보 처리방침을 열 수 없습니다. 잠시 후 다시 시도해주세요.", "logOut": "로그아웃", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 02f67e8e..1becf1cd 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -578,6 +578,18 @@ abstract class AppLocalizations { /// **'Allow App Notifications'** String get allowAppNotifications; + /// Dialog title for opening app notification settings + /// + /// In en, this message translates to: + /// **'Manage App Notifications'** + String get manageAppNotifications; + + /// Dialog description for opening app notification settings + /// + /// In en, this message translates to: + /// **'To turn app notifications on or off, update OnTime\'s notification permission in Settings.'** + String get manageAppNotificationsDescription; + /// Setting tile for opening the privacy policy /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 74224485..b6cc3e69 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -280,6 +280,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allowAppNotifications => 'Allow App Notifications'; + @override + String get manageAppNotifications => 'Manage App Notifications'; + + @override + String get manageAppNotificationsDescription => + 'To turn app notifications on or off, update OnTime\'s notification permission in Settings.'; + @override String get privacyPolicy => 'Privacy Policy'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index eb4e782b..c5e6ef09 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -261,6 +261,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get allowAppNotifications => '앱 알림 허용'; + @override + String get manageAppNotifications => '앱 알림 관리'; + + @override + String get manageAppNotificationsDescription => + '앱 알림을 켜거나 끄려면 설정에서 온타임 알림 권한을 변경해주세요.'; + @override String get privacyPolicy => '개인정보 처리방침'; diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index bca70d43..23cf953c 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -7,7 +7,6 @@ import 'package:on_time_front/core/constants/external_links.dart'; 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_entities.dart'; import 'package:on_time_front/domain/entities/preparation_entity.dart'; import 'package:on_time_front/domain/repositories/alarm_registry_repository.dart'; @@ -18,16 +17,23 @@ import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/my_page/my_page_modal/delete_user_modal.dart'; import 'package:on_time_front/presentation/my_page/my_page_modal/logout_modal.dart'; +import 'package:on_time_front/presentation/notification_allow/screens/notification_allow_screen.dart'; import 'package:on_time_front/presentation/shared/components/modal_wide_button.dart'; import 'package:on_time_front/presentation/shared/components/two_action_dialog.dart'; typedef PrivacyPolicyLauncher = Future Function(Uri uri); class MyPageScreen extends StatelessWidget { - const MyPageScreen({super.key, PrivacyPolicyLauncher? openPrivacyPolicy}) - : _openPrivacyPolicy = openPrivacyPolicy; + const MyPageScreen({ + super.key, + PrivacyPolicyLauncher? openPrivacyPolicy, + NotificationPermissionGateway notificationPermissionGateway = + const NotificationServicePermissionGateway(), + }) : _openPrivacyPolicy = openPrivacyPolicy, + _notificationPermissionGateway = notificationPermissionGateway; final PrivacyPolicyLauncher? _openPrivacyPolicy; + final NotificationPermissionGateway _notificationPermissionGateway; @override Widget build(BuildContext context) { @@ -92,11 +98,8 @@ class MyPageScreen extends StatelessWidget { if (updatedPreparation != null) {} }, ), - _SettingTile( - title: AppLocalizations.of(context)!.allowAppNotifications, - onTap: () async { - await _handleNotificationPermission(context); - }, + _NotificationStatusView( + permissionGateway: _notificationPermissionGateway, ), _SettingTile( title: AppLocalizations.of(context)!.privacyPolicy, @@ -302,7 +305,118 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { ), ], ), - Switch(value: _alarmsEnabled, onChanged: _isUpdating ? null : _toggle), + Switch( + key: const Key('alarm_permission_switch'), + value: _alarmsEnabled, + onChanged: _isUpdating ? null : _toggle, + ), + ], + ); + } +} + +class _NotificationStatusView extends StatefulWidget { + const _NotificationStatusView({required this.permissionGateway}); + + final NotificationPermissionGateway permissionGateway; + + @override + State<_NotificationStatusView> createState() => + _NotificationStatusViewState(); +} + +class _NotificationStatusViewState extends State<_NotificationStatusView> + with WidgetsBindingObserver { + bool _isLoading = true; + bool _isUpdating = false; + bool _notificationsEnabled = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _load(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + _load(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + Future _load() async { + setState(() { + _isLoading = true; + }); + final status = await widget.permissionGateway.checkNotificationPermission(); + if (!mounted) return; + setState(() { + _notificationsEnabled = _isNotificationAllowed(status); + _isLoading = false; + }); + } + + Future _toggle(bool value) async { + setState(() { + _isUpdating = true; + _notificationsEnabled = value; + }); + try { + if (value) { + final status = await widget.permissionGateway.requestPermission(); + if (_isNotificationAllowed(status)) { + await widget.permissionGateway.initializeNotifications(); + } + } else { + final shouldOpenSettings = await _showNotificationSettingsDialog( + context, + ); + if (shouldOpenSettings == true) { + await widget.permissionGateway.openNotificationSettings(); + } + } + await _load(); + } finally { + if (mounted) { + setState(() { + _isUpdating = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.allowAppNotifications, + style: textTheme.bodyLarge, + ), + const SizedBox(height: 4), + Text( + _isLoading ? '확인 중' : (_notificationsEnabled ? '켜짐' : '꺼짐'), + style: textTheme.bodySmall?.copyWith(color: colorScheme.outline), + ), + ], + ), + Switch( + key: const Key('notification_permission_switch'), + value: _notificationsEnabled, + onChanged: _isUpdating ? null : _toggle, + ), ], ); } @@ -313,6 +427,11 @@ bool _needsExactAlarmRecovery(AlarmPermissionState permission) { permission == AlarmPermissionState.notDetermined; } +bool _isNotificationAllowed(AuthorizationStatus status) { + return status == AuthorizationStatus.authorized || + status == AuthorizationStatus.provisional; +} + class _MyAccountView extends StatelessWidget { const _MyAccountView(); @@ -418,87 +537,6 @@ class _SettingTile extends StatelessWidget { } } -Future _handleNotificationPermission(BuildContext context) async { - final notificationService = NotificationService.instance; - final currentStatus = await notificationService.checkNotificationPermission(); - - if (!context.mounted) return; - - if (currentStatus == AuthorizationStatus.authorized) { - await _showAlreadyEnabledDialog(context); - } else if (currentStatus == AuthorizationStatus.denied) { - final shouldRequest = await _showPermissionRationaleDialog(context); - if (shouldRequest == true && context.mounted) { - final newStatus = await notificationService.requestPermission(); - - if (!context.mounted) return; - - if (newStatus == AuthorizationStatus.authorized) { - await notificationService.initialize(); - if (!context.mounted) return; - await _showPermissionGrantedDialog(context); - } else if (newStatus == AuthorizationStatus.denied) { - await _showGoToSettingsDialog(context); - } - } - } else if (currentStatus == AuthorizationStatus.notDetermined) { - final shouldRequest = await _showPermissionRationaleDialog(context); - if (shouldRequest == true && context.mounted) { - final newStatus = await notificationService.requestPermission(); - - if (!context.mounted) return; - - if (newStatus == AuthorizationStatus.authorized) { - await notificationService.initialize(); - if (!context.mounted) return; - await _showPermissionGrantedDialog(context); - } else if (newStatus == AuthorizationStatus.denied) { - await _showGoToSettingsDialog(context); - } - } - } else { - await _showGoToSettingsDialog(context); - } -} - -Future _showAlreadyEnabledDialog(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - - await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.notificationAlreadyEnabled, - description: l10n.notificationAlreadyEnabledDescription, - primaryAction: DialogActionConfig( - label: l10n.ok, - variant: ModalWideButtonVariant.primary, - ), - ), - ); -} - -Future _showPermissionRationaleDialog(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - - final result = await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.notificationPermissionRequired, - description: l10n.notificationPermissionRequiredDescription, - secondaryAction: DialogActionConfig( - label: l10n.cancel, - variant: ModalWideButtonVariant.neutral, - ), - primaryAction: DialogActionConfig( - label: l10n.allow, - variant: ModalWideButtonVariant.primary, - ), - ), - ); - - return result == DialogActionResult.primary; -} - Future _showExactAlarmPermissionDialog( BuildContext context, ) async { @@ -521,30 +559,14 @@ Future _showExactAlarmPermissionDialog( ); } -Future _showPermissionGrantedDialog(BuildContext context) async { - final l10n = AppLocalizations.of(context)!; - - await showTwoActionDialog( - context, - config: TwoActionDialogConfig( - title: l10n.notificationPermissionGranted, - description: l10n.notificationPermissionGrantedDescription, - primaryAction: DialogActionConfig( - label: l10n.ok, - variant: ModalWideButtonVariant.primary, - ), - ), - ); -} - -Future _showGoToSettingsDialog(BuildContext context) async { +Future _showNotificationSettingsDialog(BuildContext context) async { final l10n = AppLocalizations.of(context)!; final result = await showTwoActionDialog( context, config: TwoActionDialogConfig( - title: l10n.openNotificationSettings, - description: l10n.openNotificationSettingsDescription, + title: l10n.manageAppNotifications, + description: l10n.manageAppNotificationsDescription, secondaryAction: DialogActionConfig( label: l10n.cancel, variant: ModalWideButtonVariant.neutral, @@ -556,7 +578,5 @@ Future _showGoToSettingsDialog(BuildContext context) async { ), ); - if (result == DialogActionResult.primary) { - await NotificationService.instance.openNotificationSettings(); - } + return result == DialogActionResult.primary; } diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index 287e76f3..7bd59742 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -13,6 +13,8 @@ abstract interface class NotificationPermissionGateway { Future requestPermission(); + Future initializeNotifications(); + Future openNotificationSettings(); } @@ -25,6 +27,11 @@ class NotificationServicePermissionGateway return NotificationService.instance.checkNotificationPermission(); } + @override + Future initializeNotifications() { + return NotificationService.instance.initialize(); + } + @override Future openNotificationSettings() { return NotificationService.instance.openNotificationSettings(); diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart index 9df16783..39d6059d 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -1,3 +1,4 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,6 +16,7 @@ import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart'; import 'package:on_time_front/l10n/app_localizations.dart'; import 'package:on_time_front/presentation/app/bloc/auth/auth_bloc.dart'; import 'package:on_time_front/presentation/my_page/my_page_screen.dart'; +import 'package:on_time_front/presentation/notification_allow/screens/notification_allow_screen.dart'; import 'package:on_time_front/presentation/shared/theme/theme.dart'; void main() { @@ -87,6 +89,72 @@ void main() { expect(openedUris, [ExternalLinks.privacyPolicyUri]); }); + testWidgets('shows notification permission switch on My Page', ( + tester, + ) async { + await _pumpMyPage(tester, locale: const Locale('en')); + + expect(find.text('Allow App Notifications'), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('notification_permission_switch')), + ) + .value, + isFalse, + ); + }); + + testWidgets('turning notification switch on requests permission', ( + tester, + ) async { + final notificationGateway = _FakeNotificationPermissionGateway( + currentStatus: AuthorizationStatus.denied, + requestedStatus: AuthorizationStatus.authorized, + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + notificationPermissionGateway: notificationGateway, + ); + + await tester.tap(find.byKey(const Key('notification_permission_switch'))); + await tester.pumpAndSettle(); + + expect(notificationGateway.requestCount, 1); + expect(notificationGateway.initializeCount, 1); + expect( + tester + .widget( + find.byKey(const Key('notification_permission_switch')), + ) + .value, + isTrue, + ); + }); + + testWidgets('turning notification switch off opens settings path', ( + tester, + ) async { + final notificationGateway = _FakeNotificationPermissionGateway( + currentStatus: AuthorizationStatus.authorized, + ); + + await _pumpMyPage( + tester, + locale: const Locale('en'), + notificationPermissionGateway: notificationGateway, + ); + + await tester.tap(find.byKey(const Key('notification_permission_switch'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Settings')); + await tester.pumpAndSettle(); + + expect(notificationGateway.openSettingsCount, 1); + }); + testWidgets('keeps alarms disabled when exact alarm permission is missing', ( tester, ) async { @@ -106,14 +174,19 @@ void main() { await _pumpMyPage(tester, locale: const Locale('en')); - await tester.tap(find.byType(Switch)); + await tester.tap(find.byKey(const Key('alarm_permission_switch'))); 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(tester.widget(find.byType(Switch)).value, isFalse); + expect( + tester + .widget(find.byKey(const Key('alarm_permission_switch'))) + .value, + isFalse, + ); }); } @@ -121,7 +194,10 @@ Future _pumpMyPage( WidgetTester tester, { required Locale locale, PrivacyPolicyLauncher? openPrivacyPolicy, + NotificationPermissionGateway? notificationPermissionGateway, }) async { + final notificationGateway = + notificationPermissionGateway ?? _FakeNotificationPermissionGateway(); await tester.pumpWidget( MaterialApp( theme: themeData, @@ -130,13 +206,53 @@ Future _pumpMyPage( supportedLocales: AppLocalizations.supportedLocales, home: BlocProvider.value( value: _StubAuthBloc(), - child: MyPageScreen(openPrivacyPolicy: openPrivacyPolicy), + child: MyPageScreen( + openPrivacyPolicy: openPrivacyPolicy, + notificationPermissionGateway: notificationGateway, + ), ), ), ); await tester.pumpAndSettle(); } +class _FakeNotificationPermissionGateway + implements NotificationPermissionGateway { + _FakeNotificationPermissionGateway({ + this.currentStatus = AuthorizationStatus.denied, + this.requestedStatus = AuthorizationStatus.denied, + }); + + AuthorizationStatus currentStatus; + final AuthorizationStatus requestedStatus; + int requestCount = 0; + int initializeCount = 0; + int openSettingsCount = 0; + + @override + Future checkNotificationPermission() async { + return currentStatus; + } + + @override + Future initializeNotifications() async { + initializeCount += 1; + } + + @override + Future openNotificationSettings() async { + openSettingsCount += 1; + return true; + } + + @override + Future requestPermission() async { + requestCount += 1; + currentStatus = requestedStatus; + return requestedStatus; + } +} + class _StubAuthBloc extends Mock implements AuthBloc { @override AuthState get state => const AuthState.loading(); diff --git a/test/presentation/notification_allow/notification_allow_screen_test.dart b/test/presentation/notification_allow/notification_allow_screen_test.dart index ebde1868..83b5cb15 100644 --- a/test/presentation/notification_allow/notification_allow_screen_test.dart +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -232,6 +232,9 @@ class _FakePermissionGateway implements NotificationPermissionGateway { return currentStatus; } + @override + Future initializeNotifications() async {} + @override Future openNotificationSettings() async { openSettingsCount += 1;