diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 28aa2372..8786969d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -339,14 +339,6 @@ "@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 e3d6eb69..d6dcb735 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -79,8 +79,6 @@ "accountSettings": "계정 설정", "editDefaultPreparation": "기본 준비과정 / 여유시간 수정", "allowAppNotifications": "앱 알림 허용", - "manageAppNotifications": "앱 알림 관리", - "manageAppNotificationsDescription": "앱 알림을 켜거나 끄려면 설정에서 온타임 알림 권한을 변경해주세요.", "privacyPolicy": "개인정보 처리방침", "privacyPolicyOpenError": "개인정보 처리방침을 열 수 없습니다. 잠시 후 다시 시도해주세요.", "logOut": "로그아웃", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1becf1cd..02f67e8e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -578,18 +578,6 @@ 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 b6cc3e69..74224485 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -280,13 +280,6 @@ 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 c5e6ef09..eb4e782b 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -261,13 +261,6 @@ 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 23cf953c..bca70d43 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -7,6 +7,7 @@ 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'; @@ -17,23 +18,16 @@ 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, - NotificationPermissionGateway notificationPermissionGateway = - const NotificationServicePermissionGateway(), - }) : _openPrivacyPolicy = openPrivacyPolicy, - _notificationPermissionGateway = notificationPermissionGateway; + const MyPageScreen({super.key, PrivacyPolicyLauncher? openPrivacyPolicy}) + : _openPrivacyPolicy = openPrivacyPolicy; final PrivacyPolicyLauncher? _openPrivacyPolicy; - final NotificationPermissionGateway _notificationPermissionGateway; @override Widget build(BuildContext context) { @@ -98,8 +92,11 @@ class MyPageScreen extends StatelessWidget { if (updatedPreparation != null) {} }, ), - _NotificationStatusView( - permissionGateway: _notificationPermissionGateway, + _SettingTile( + title: AppLocalizations.of(context)!.allowAppNotifications, + onTap: () async { + await _handleNotificationPermission(context); + }, ), _SettingTile( title: AppLocalizations.of(context)!.privacyPolicy, @@ -305,118 +302,7 @@ class _AlarmStatusViewState extends State<_AlarmStatusView> { ), ], ), - 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, - ), + Switch(value: _alarmsEnabled, onChanged: _isUpdating ? null : _toggle), ], ); } @@ -427,11 +313,6 @@ bool _needsExactAlarmRecovery(AlarmPermissionState permission) { permission == AlarmPermissionState.notDetermined; } -bool _isNotificationAllowed(AuthorizationStatus status) { - return status == AuthorizationStatus.authorized || - status == AuthorizationStatus.provisional; -} - class _MyAccountView extends StatelessWidget { const _MyAccountView(); @@ -537,6 +418,87 @@ 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 { @@ -559,14 +521,30 @@ Future _showExactAlarmPermissionDialog( ); } -Future _showNotificationSettingsDialog(BuildContext context) async { +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 { final l10n = AppLocalizations.of(context)!; final result = await showTwoActionDialog( context, config: TwoActionDialogConfig( - title: l10n.manageAppNotifications, - description: l10n.manageAppNotificationsDescription, + title: l10n.openNotificationSettings, + description: l10n.openNotificationSettingsDescription, secondaryAction: DialogActionConfig( label: l10n.cancel, variant: ModalWideButtonVariant.neutral, @@ -578,5 +556,7 @@ Future _showNotificationSettingsDialog(BuildContext context) async { ), ); - return result == DialogActionResult.primary; + if (result == DialogActionResult.primary) { + await NotificationService.instance.openNotificationSettings(); + } } diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index 7bd59742..287e76f3 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -13,8 +13,6 @@ abstract interface class NotificationPermissionGateway { Future requestPermission(); - Future initializeNotifications(); - Future openNotificationSettings(); } @@ -27,11 +25,6 @@ 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 39d6059d..9df16783 100644 --- a/test/presentation/my_page/my_page_screen_test.dart +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -1,4 +1,3 @@ -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'; @@ -16,7 +15,6 @@ 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() { @@ -89,72 +87,6 @@ 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 { @@ -174,19 +106,14 @@ void main() { await _pumpMyPage(tester, locale: const Locale('en')); - await tester.tap(find.byKey(const Key('alarm_permission_switch'))); + 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.byKey(const Key('alarm_permission_switch'))) - .value, - isFalse, - ); + expect(tester.widget(find.byType(Switch)).value, isFalse); }); } @@ -194,10 +121,7 @@ Future _pumpMyPage( WidgetTester tester, { required Locale locale, PrivacyPolicyLauncher? openPrivacyPolicy, - NotificationPermissionGateway? notificationPermissionGateway, }) async { - final notificationGateway = - notificationPermissionGateway ?? _FakeNotificationPermissionGateway(); await tester.pumpWidget( MaterialApp( theme: themeData, @@ -206,53 +130,13 @@ Future _pumpMyPage( supportedLocales: AppLocalizations.supportedLocales, home: BlocProvider.value( value: _StubAuthBloc(), - child: MyPageScreen( - openPrivacyPolicy: openPrivacyPolicy, - notificationPermissionGateway: notificationGateway, - ), + child: MyPageScreen(openPrivacyPolicy: openPrivacyPolicy), ), ), ); 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 83b5cb15..ebde1868 100644 --- a/test/presentation/notification_allow/notification_allow_screen_test.dart +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -232,9 +232,6 @@ class _FakePermissionGateway implements NotificationPermissionGateway { return currentStatus; } - @override - Future initializeNotifications() async {} - @override Future openNotificationSettings() async { openSettingsCount += 1;