diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index abbeecb4..46e171be 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -59,7 +59,7 @@ "@pleaseAllowNotifications": { "description": "Title asking the user to allow notifications" }, - "notificationPermissionDescription": "OnTime needs notifications to help you get ready", + "notificationPermissionDescription": "OnTime sends schedule preparation reminders so you can get ready on time.", "@notificationPermissionDescription": { "description": "Description explaining why notification permission is needed" }, @@ -464,7 +464,7 @@ "@notificationAlreadyEnabled": { "description": "Dialog title when notification is already enabled" }, - "notificationAlreadyEnabledDescription": "App notifications are currently active.", + "notificationAlreadyEnabledDescription": "Schedule preparation reminders are currently active.", "@notificationAlreadyEnabledDescription": { "description": "Dialog content when notification is already enabled" }, @@ -472,7 +472,7 @@ "@notificationPermissionRequired": { "description": "Dialog title when requesting notification permission" }, - "notificationPermissionRequiredDescription": "We'll send you notifications so you don't miss your appointments.\nWould you like to allow notifications?", + "notificationPermissionRequiredDescription": "OnTime uses notifications for schedule preparation reminders and appointment alerts.\nWould you like to allow notifications?", "@notificationPermissionRequiredDescription": { "description": "Dialog content when requesting notification permission" }, @@ -484,7 +484,7 @@ "@notificationPermissionGranted": { "description": "Dialog title when notification permission is granted" }, - "notificationPermissionGrantedDescription": "Notifications have been successfully activated.", + "notificationPermissionGrantedDescription": "Schedule preparation reminders are now active.", "@notificationPermissionGrantedDescription": { "description": "Dialog content when notification permission is granted" }, @@ -492,7 +492,7 @@ "@openNotificationSettings": { "description": "Dialog title to open notification settings" }, - "openNotificationSettingsDescription": "Notification permission was denied.\nPlease allow notifications in Settings.", + "openNotificationSettingsDescription": "Notification permission was denied.\nTo receive schedule preparation reminders, please allow notifications in Settings.", "@openNotificationSettingsDescription": { "description": "Dialog content to open notification settings" }, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 682cbab4..efcf535c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -14,7 +14,7 @@ "allowNotifications": "알림 허용하기", "doItLater": "나중에 할게요.", "pleaseAllowNotifications": "알림을 허용해주세요", - "notificationPermissionDescription": "알림을 허용해야 온타임이 준비를 \n도와드릴 수 있어요", + "notificationPermissionDescription": "약속 준비 리마인더를 보내\n제시간에 준비할 수 있게 도와드려요.", "late": " 지각했어요", "early": " 일찍 준비했어요", "letsGo": "까먹지 않고 출발", @@ -157,13 +157,13 @@ "description": "Notification body text for continuing preparation" }, "notificationAlreadyEnabled": "알림이 이미 허용됨", - "notificationAlreadyEnabledDescription": "현재 앱 알림이 활성화되어 있습니다.", + "notificationAlreadyEnabledDescription": "약속 준비 리마인더가 현재 활성화되어 있습니다.", "notificationPermissionRequired": "알림 권한 필요", - "notificationPermissionRequiredDescription": "약속 시간을 놓치지 않도록 알림을 보내드립니다.\n알림을 허용하시겠습니까?", + "notificationPermissionRequiredDescription": "온타임은 약속 준비 리마인더와 약속 알림을 보내기 위해 알림을 사용합니다.\n알림을 허용하시겠습니까?", "allow": "허용", "notificationPermissionGranted": "알림 허용 완료", - "notificationPermissionGrantedDescription": "알림이 성공적으로 활성화되었습니다.", + "notificationPermissionGrantedDescription": "약속 준비 리마인더가 활성화되었습니다.", "openNotificationSettings": "설정에서 알림 허용", - "openNotificationSettingsDescription": "알림 권한이 거부되었습니다.\n설정에서 직접 알림을 허용해주세요.", + "openNotificationSettingsDescription": "알림 권한이 거부되었습니다.\n약속 준비 리마인더를 받으려면 설정에서 알림을 허용해주세요.", "openSettings": "설정 열기" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 54a3610b..bbca4546 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -191,7 +191,7 @@ abstract class AppLocalizations { /// Description explaining why notification permission is needed /// /// In en, this message translates to: - /// **'OnTime needs notifications to help you get ready'** + /// **'OnTime sends schedule preparation reminders so you can get ready on time.'** String get notificationPermissionDescription; /// Appended to the time when the user is late @@ -725,7 +725,7 @@ abstract class AppLocalizations { /// Dialog content when notification is already enabled /// /// In en, this message translates to: - /// **'App notifications are currently active.'** + /// **'Schedule preparation reminders are currently active.'** String get notificationAlreadyEnabledDescription; /// Dialog title when requesting notification permission @@ -737,7 +737,7 @@ abstract class AppLocalizations { /// Dialog content when requesting notification permission /// /// In en, this message translates to: - /// **'We\'ll send you notifications so you don\'t miss your appointments.\nWould you like to allow notifications?'** + /// **'OnTime uses notifications for schedule preparation reminders and appointment alerts.\nWould you like to allow notifications?'** String get notificationPermissionRequiredDescription; /// Button text to allow permission @@ -755,7 +755,7 @@ abstract class AppLocalizations { /// Dialog content when notification permission is granted /// /// In en, this message translates to: - /// **'Notifications have been successfully activated.'** + /// **'Schedule preparation reminders are now active.'** String get notificationPermissionGrantedDescription; /// Dialog title to open notification settings @@ -767,7 +767,7 @@ abstract class AppLocalizations { /// Dialog content to open notification settings /// /// In en, this message translates to: - /// **'Notification permission was denied.\nPlease allow notifications in Settings.'** + /// **'Notification permission was denied.\nTo receive schedule preparation reminders, please allow notifications in Settings.'** String get openNotificationSettingsDescription; /// Button text to open app settings diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b47d78f9..09a97fb8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -57,7 +57,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationPermissionDescription => - 'OnTime needs notifications to help you get ready'; + 'OnTime sends schedule preparation reminders so you can get ready on time.'; @override String get late => ' late'; @@ -370,7 +370,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationAlreadyEnabledDescription => - 'App notifications are currently active.'; + 'Schedule preparation reminders are currently active.'; @override String get notificationPermissionRequired => @@ -378,7 +378,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationPermissionRequiredDescription => - 'We\'ll send you notifications so you don\'t miss your appointments.\nWould you like to allow notifications?'; + 'OnTime uses notifications for schedule preparation reminders and appointment alerts.\nWould you like to allow notifications?'; @override String get allow => 'Allow'; @@ -388,14 +388,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationPermissionGrantedDescription => - 'Notifications have been successfully activated.'; + 'Schedule preparation reminders are now active.'; @override String get openNotificationSettings => 'Allow Notifications in Settings'; @override String get openNotificationSettingsDescription => - 'Notification permission was denied.\nPlease allow notifications in Settings.'; + 'Notification permission was denied.\nTo receive schedule preparation reminders, please allow notifications in Settings.'; @override String get openSettings => 'Open Settings'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 460ddda6..17f82f23 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -55,7 +55,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notificationPermissionDescription => - '알림을 허용해야 온타임이 준비를 \n도와드릴 수 있어요'; + '약속 준비 리마인더를 보내\n제시간에 준비할 수 있게 도와드려요.'; @override String get late => ' 지각했어요'; @@ -345,14 +345,15 @@ class AppLocalizationsKo extends AppLocalizations { String get notificationAlreadyEnabled => '알림이 이미 허용됨'; @override - String get notificationAlreadyEnabledDescription => '현재 앱 알림이 활성화되어 있습니다.'; + String get notificationAlreadyEnabledDescription => + '약속 준비 리마인더가 현재 활성화되어 있습니다.'; @override String get notificationPermissionRequired => '알림 권한 필요'; @override String get notificationPermissionRequiredDescription => - '약속 시간을 놓치지 않도록 알림을 보내드립니다.\n알림을 허용하시겠습니까?'; + '온타임은 약속 준비 리마인더와 약속 알림을 보내기 위해 알림을 사용합니다.\n알림을 허용하시겠습니까?'; @override String get allow => '허용'; @@ -361,14 +362,15 @@ class AppLocalizationsKo extends AppLocalizations { String get notificationPermissionGranted => '알림 허용 완료'; @override - String get notificationPermissionGrantedDescription => '알림이 성공적으로 활성화되었습니다.'; + String get notificationPermissionGrantedDescription => + '약속 준비 리마인더가 활성화되었습니다.'; @override String get openNotificationSettings => '설정에서 알림 허용'; @override String get openNotificationSettingsDescription => - '알림 권한이 거부되었습니다.\n설정에서 직접 알림을 허용해주세요.'; + '알림 권한이 거부되었습니다.\n약속 준비 리마인더를 받으려면 설정에서 알림을 허용해주세요.'; @override String get openSettings => '설정 열기'; diff --git a/lib/presentation/notification_allow/screens/notification_allow_screen.dart b/lib/presentation/notification_allow/screens/notification_allow_screen.dart index ce68b905..b3c30f3f 100644 --- a/lib/presentation/notification_allow/screens/notification_allow_screen.dart +++ b/lib/presentation/notification_allow/screens/notification_allow_screen.dart @@ -10,8 +10,41 @@ import 'package:on_time_front/presentation/shared/components/modal_wide_button.d 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 { + Future checkNotificationPermission(); + + Future requestPermission(); + + Future openNotificationSettings(); +} + +class NotificationServicePermissionGateway + implements NotificationPermissionGateway { + const NotificationServicePermissionGateway(); + + @override + Future checkNotificationPermission() { + return NotificationService.instance.checkNotificationPermission(); + } + + @override + Future openNotificationSettings() { + return NotificationService.instance.openNotificationSettings(); + } + + @override + Future requestPermission() { + return NotificationService.instance.requestPermission(); + } +} + class NotificationAllowScreen extends StatelessWidget { - const NotificationAllowScreen({super.key}); + const NotificationAllowScreen({ + super.key, + this.permissionGateway = const NotificationServicePermissionGateway(), + }); + + final NotificationPermissionGateway permissionGateway; @override Widget build(BuildContext context) { @@ -31,14 +64,11 @@ class NotificationAllowScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, spacing: 40, - children: [ - _Image(), - _Title(), - ], + children: [_Image(), _Title()], ), ), ), - _Buttons(), + _Buttons(permissionGateway: permissionGateway), ], ), ), @@ -47,7 +77,10 @@ class NotificationAllowScreen extends StatelessWidget { } class _Buttons extends StatelessWidget { - const _Buttons(); + const _Buttons({required this.permissionGateway}); + + final NotificationPermissionGateway permissionGateway; + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; @@ -60,7 +93,7 @@ class _Buttons extends StatelessWidget { children: [ FilledButton( onPressed: () async { - await _handleNotificationPermission(context); + await _handleNotificationPermission(context, permissionGateway); }, child: Text( AppLocalizations.of(context)!.allowNotifications, @@ -110,18 +143,14 @@ class _Title extends StatelessWidget { Text( AppLocalizations.of(context)!.pleaseAllowNotifications, textAlign: TextAlign.center, - style: textTheme.headlineMedium?.copyWith( - color: colorScheme.primary, - ), + style: textTheme.headlineMedium?.copyWith(color: colorScheme.primary), ), SizedBox( width: 282, child: Text( AppLocalizations.of(context)!.notificationPermissionDescription, textAlign: TextAlign.center, - style: textTheme.titleMedium?.copyWith( - color: colorScheme.outline, - ), + style: textTheme.titleMedium?.copyWith(color: colorScheme.outline), ), ), ], @@ -141,25 +170,22 @@ class _Image extends StatelessWidget { padding: const EdgeInsets.all(17.50), decoration: ShapeDecoration( color: colorScheme.primaryContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(35), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(35)), ), child: SvgPicture.asset( 'bell-ringing.svg', package: 'assets', - colorFilter: ColorFilter.mode( - colorScheme.primary, - BlendMode.srcIn, - ), + colorFilter: ColorFilter.mode(colorScheme.primary, BlendMode.srcIn), ), ); } } -Future _handleNotificationPermission(BuildContext context) async { - final notificationService = NotificationService.instance; - final currentStatus = await notificationService.checkNotificationPermission(); +Future _handleNotificationPermission( + BuildContext context, + NotificationPermissionGateway permissionGateway, +) async { + final currentStatus = await permissionGateway.checkNotificationPermission(); if (!context.mounted) return; @@ -171,7 +197,7 @@ Future _handleNotificationPermission(BuildContext context) async { } else if (currentStatus == AuthorizationStatus.denied) { final shouldOpenSettings = await _showGoToSettingsDialog(context); if (shouldOpenSettings == true) { - await notificationService.openNotificationSettings(); + await permissionGateway.openNotificationSettings(); } else if (context.mounted) { await context.read().dismissPrompt(); if (context.mounted) { @@ -179,7 +205,7 @@ Future _handleNotificationPermission(BuildContext context) async { } } } else if (currentStatus == AuthorizationStatus.notDetermined) { - final newStatus = await notificationService.requestPermission(); + final newStatus = await permissionGateway.requestPermission(); if (!context.mounted) return; @@ -191,7 +217,7 @@ Future _handleNotificationPermission(BuildContext context) async { } else if (newStatus == AuthorizationStatus.denied) { final shouldOpenSettings = await _showGoToSettingsDialog(context); if (shouldOpenSettings == true) { - await notificationService.openNotificationSettings(); + await permissionGateway.openNotificationSettings(); } else if (context.mounted) { await context.read().dismissPrompt(); if (context.mounted) { @@ -202,7 +228,7 @@ Future _handleNotificationPermission(BuildContext context) async { } else { final shouldOpenSettings = await _showGoToSettingsDialog(context); if (shouldOpenSettings == true) { - await notificationService.openNotificationSettings(); + await permissionGateway.openNotificationSettings(); } else if (context.mounted) { await context.read().dismissPrompt(); if (context.mounted) { diff --git a/plans/443-notification-permission-ux.md b/plans/443-notification-permission-ux.md new file mode 100644 index 00000000..173306d1 --- /dev/null +++ b/plans/443-notification-permission-ux.md @@ -0,0 +1,47 @@ +# Issue #443: Verify Notification Permission UX + +Parent track: #465 Android permissions and alarm policy + +## Status + +#443 is codex-ready. It has no prerequisites and is scoped to notification permission UX only. + +## Scope + +- Ensure pre-permission/contextual copy explains notifications support schedule preparation and reminders. +- Cover Android 13+ style runtime permission outcomes in code-level tests where practical: + - already granted + - first request granted + - request denied + - denied/settings recovery +- Check Korean and English strings. + +## Likely Files + +- `lib/presentation/notification_allow/screens/notification_allow_screen.dart` +- `lib/presentation/my_page/my_page_screen.dart` +- `lib/l10n/app_en.arb` +- `lib/l10n/app_ko.arb` +- generated localization files under `lib/l10n/` +- new or updated widget tests under `test/presentation/notification_allow/` + +## Implementation Approach + +- Keep existing permission routing and notification gate behavior. +- Update the onboarding notification permission rationale copy and settings-recovery copy in both English and Korean to explicitly mention schedule preparation/reminders. +- Make the notification allow screen permission handler testable through a small injectable abstraction, without changing app behavior. +- Add focused widget tests for granted, denied, settings-open, and localized copy paths. + +## Verification + +- `flutter gen-l10n` +- `dart format lib test` +- targeted widget tests for notification permission UX +- `flutter analyze` + +## Left Out + +- Exact alarm permission UX (#444). +- Android manifest permission audit (#442). +- Full-screen intent declaration (#445). +- Device-level alarm/notification QA (#457). diff --git a/test/presentation/notification_allow/notification_allow_screen_test.dart b/test/presentation/notification_allow/notification_allow_screen_test.dart new file mode 100644 index 00000000..e0e9da66 --- /dev/null +++ b/test/presentation/notification_allow/notification_allow_screen_test.dart @@ -0,0 +1,252 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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/notification_allow/screens/notification_allow_screen.dart'; +import 'package:on_time_front/presentation/shared/theme/theme.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('shows English schedule preparation reminder rationale', ( + tester, + ) async { + final harness = await _pumpNotificationAllowScreen( + tester, + locale: const Locale('en'), + permissionGateway: _FakePermissionGateway( + currentStatus: AuthorizationStatus.notDetermined, + ), + ); + addTearDown(harness.dispose); + + expect( + find.text( + 'OnTime sends schedule preparation reminders so you can get ready on time.', + ), + findsOneWidget, + ); + }); + + testWidgets('shows Korean schedule preparation reminder rationale', ( + tester, + ) async { + final harness = await _pumpNotificationAllowScreen( + tester, + locale: const Locale('ko'), + permissionGateway: _FakePermissionGateway( + currentStatus: AuthorizationStatus.notDetermined, + ), + ); + addTearDown(harness.dispose); + + expect(find.text('약속 준비 리마인더를 보내\n제시간에 준비할 수 있게 도와드려요.'), findsOneWidget); + }); + + testWidgets('granted permission marks gate allowed and continues home', ( + tester, + ) async { + final gateService = _FakeNotificationService(); + final harness = await _pumpNotificationAllowScreen( + tester, + permissionGateway: _FakePermissionGateway( + currentStatus: AuthorizationStatus.authorized, + ), + gateService: gateService, + ); + addTearDown(harness.dispose); + + await tester.tap(find.text('Allow notifications')); + await tester.pumpAndSettle(); + + expect(find.text('home'), findsOneWidget); + expect(gateService.initializeCount, greaterThanOrEqualTo(1)); + expect(harness.gateCubit.state.status, NotificationGateStatus.allowed); + }); + + testWidgets('not determined permission can be granted from system prompt', ( + tester, + ) async { + final gateService = _FakeNotificationService(); + final permissionGateway = _FakePermissionGateway( + currentStatus: AuthorizationStatus.notDetermined, + requestedStatus: AuthorizationStatus.authorized, + ); + final harness = await _pumpNotificationAllowScreen( + tester, + permissionGateway: permissionGateway, + gateService: gateService, + ); + addTearDown(harness.dispose); + + await tester.tap(find.text('Allow notifications')); + await tester.pumpAndSettle(); + + expect(permissionGateway.requestCount, 1); + expect(find.text('home'), findsOneWidget); + expect(harness.gateCubit.state.status, NotificationGateStatus.allowed); + }); + + testWidgets('denied permission opens settings recovery path', (tester) async { + final permissionGateway = _FakePermissionGateway( + currentStatus: AuthorizationStatus.denied, + ); + final harness = await _pumpNotificationAllowScreen( + tester, + permissionGateway: permissionGateway, + ); + addTearDown(harness.dispose); + + 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); + }); + + testWidgets('request denial lets user dismiss prompt and continue home', ( + tester, + ) async { + final permissionGateway = _FakePermissionGateway( + currentStatus: AuthorizationStatus.notDetermined, + requestedStatus: AuthorizationStatus.denied, + ); + final harness = await _pumpNotificationAllowScreen( + tester, + permissionGateway: permissionGateway, + ); + addTearDown(harness.dispose); + + await tester.tap(find.text('Allow notifications')); + 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); + }); +} + +Future<_NotificationAllowHarness> _pumpNotificationAllowScreen( + WidgetTester tester, { + Locale locale = const Locale('en'), + required NotificationPermissionGateway permissionGateway, + _FakeNotificationService? gateService, +}) async { + final notificationService = gateService ?? _FakeNotificationService(); + final gateCubit = NotificationGateCubit( + notificationService: notificationService, + ); + final router = GoRouter( + initialLocation: '/allowNotification', + routes: [ + GoRoute( + path: '/allowNotification', + builder: (context, state) => BlocProvider.value( + value: gateCubit, + child: NotificationAllowScreen(permissionGateway: permissionGateway), + ), + ), + 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 _NotificationAllowHarness(gateCubit: gateCubit, router: router); +} + +class _NotificationAllowHarness { + _NotificationAllowHarness({required this.gateCubit, required this.router}); + + final NotificationGateCubit gateCubit; + final GoRouter router; + + void dispose() { + gateCubit.close(); + router.dispose(); + } +} + +class _FakePermissionGateway implements NotificationPermissionGateway { + _FakePermissionGateway({ + required this.currentStatus, + this.requestedStatus = AuthorizationStatus.denied, + }); + + AuthorizationStatus currentStatus; + final AuthorizationStatus requestedStatus; + int requestCount = 0; + int openSettingsCount = 0; + + @override + Future checkNotificationPermission() async { + return currentStatus; + } + + @override + Future openNotificationSettings() async { + openSettingsCount += 1; + return true; + } + + @override + Future requestPermission() async { + requestCount += 1; + currentStatus = requestedStatus; + return requestedStatus; + } +} + +class _FakeNotificationService implements NotificationService { + _FakeNotificationService(); + + int initializeCount = 0; + + @override + Future checkNotificationPermission() async { + return AuthorizationStatus.notDetermined; + } + + @override + Future initialize() async { + initializeCount += 1; + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}