diff --git a/lib/core/constants/external_links.dart b/lib/core/constants/external_links.dart new file mode 100644 index 00000000..e20c311d --- /dev/null +++ b/lib/core/constants/external_links.dart @@ -0,0 +1,5 @@ +final class ExternalLinks { + static final privacyPolicyUri = Uri.parse( + 'https://ontime-back.duckdns.org/privacy-policy', + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8d38f6cc..54ec6f45 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -327,6 +327,14 @@ "@allowAppNotifications": { "description": "Setting tile for allowing app notifications" }, + "privacyPolicy": "Privacy Policy", + "@privacyPolicy": { + "description": "Setting tile for opening the privacy policy" + }, + "privacyPolicyOpenError": "Could not open the privacy policy. Please try again later.", + "@privacyPolicyOpenError": { + "description": "Dialog message shown when the privacy policy link cannot be opened" + }, "logOut": "Log out", "@logOut": { "description": "Setting tile for logging out" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6113fdaa..577ccbf5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -76,6 +76,8 @@ "accountSettings": "계정 설정", "editDefaultPreparation": "기본 준비과정 / 여유시간 수정", "allowAppNotifications": "앱 알림 허용", + "privacyPolicy": "개인정보 처리방침", + "privacyPolicyOpenError": "개인정보 처리방침을 열 수 없습니다. 잠시 후 다시 시도해주세요.", "logOut": "로그아웃", "deleteAccount": "회원 탈퇴", "editSpareTime": "여유시간 수정", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index eeb52ee0..9062afb6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -560,6 +560,18 @@ abstract class AppLocalizations { /// **'Allow App Notifications'** String get allowAppNotifications; + /// Setting tile for opening the privacy policy + /// + /// In en, this message translates to: + /// **'Privacy Policy'** + String get privacyPolicy; + + /// Dialog message shown when the privacy policy link cannot be opened + /// + /// In en, this message translates to: + /// **'Could not open the privacy policy. Please try again later.'** + String get privacyPolicyOpenError; + /// Setting tile for logging out /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 076f66bc..cbb79348 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -270,6 +270,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allowAppNotifications => 'Allow App Notifications'; + @override + String get privacyPolicy => 'Privacy Policy'; + + @override + String get privacyPolicyOpenError => + 'Could not open the privacy policy. Please try again later.'; + @override String get logOut => 'Log out'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e27968c1..657e4196 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -251,6 +251,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get allowAppNotifications => '앱 알림 허용'; + @override + String get privacyPolicy => '개인정보 처리방침'; + + @override + String get privacyPolicyOpenError => '개인정보 처리방침을 열 수 없습니다. 잠시 후 다시 시도해주세요.'; + @override String get logOut => '로그아웃'; diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 78bf7a61..62a86396 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -2,6 +2,8 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +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'; @@ -19,8 +21,13 @@ import 'package:on_time_front/presentation/my_page/my_page_modal/logout_modal.da 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}); + const MyPageScreen({super.key, PrivacyPolicyLauncher? openPrivacyPolicy}) + : _openPrivacyPolicy = openPrivacyPolicy; + + final PrivacyPolicyLauncher? _openPrivacyPolicy; @override Widget build(BuildContext context) { @@ -91,6 +98,15 @@ class MyPageScreen extends StatelessWidget { await _handleNotificationPermission(context); }, ), + _SettingTile( + title: AppLocalizations.of(context)!.privacyPolicy, + onTap: () async { + await _handlePrivacyPolicyTap( + context, + _openPrivacyPolicy ?? _openPrivacyPolicyExternally, + ); + }, + ), ], ), ), @@ -101,6 +117,37 @@ class MyPageScreen extends StatelessWidget { } } +Future _openPrivacyPolicyExternally(Uri uri) { + return launchUrl(uri, mode: LaunchMode.externalApplication); +} + +Future _handlePrivacyPolicyTap( + BuildContext context, + PrivacyPolicyLauncher openPrivacyPolicy, +) async { + var opened = false; + try { + opened = await openPrivacyPolicy(ExternalLinks.privacyPolicyUri); + } catch (_) { + opened = false; + } + + if (opened || !context.mounted) return; + + final l10n = AppLocalizations.of(context)!; + await showTwoActionDialog( + context, + config: TwoActionDialogConfig( + title: l10n.error, + description: l10n.privacyPolicyOpenError, + primaryAction: DialogActionConfig( + label: l10n.ok, + variant: ModalWideButtonVariant.primary, + ), + ), + ); +} + class _AlarmStatusView extends StatefulWidget { const _AlarmStatusView(); diff --git a/test/presentation/my_page/my_page_screen_test.dart b/test/presentation/my_page/my_page_screen_test.dart new file mode 100644 index 00000000..e18b736d --- /dev/null +++ b/test/presentation/my_page/my_page_screen_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +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/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/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/shared/theme/theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await getIt.reset(); + getIt + ..registerSingleton(_FakeAlarmRepository()) + ..registerSingleton(_FakeAlarmRegistry()) + ..registerSingleton(_FakeAlarmSchedulerService()) + ..registerSingleton( + _FakeFallbackAlarmNotificationService(), + ); + }); + + tearDown(() async { + await getIt.reset(); + }); + + testWidgets('shows English privacy policy setting', (tester) async { + await _pumpMyPage(tester, locale: const Locale('en')); + + expect(find.text('Privacy Policy'), findsOneWidget); + }); + + testWidgets('shows Korean privacy policy setting', (tester) async { + await _pumpMyPage(tester, locale: const Locale('ko')); + + expect(find.text('개인정보 처리방침'), findsOneWidget); + }); + + testWidgets('opens hosted privacy policy URL from setting', (tester) async { + final openedUris = []; + + await _pumpMyPage( + tester, + locale: const Locale('en'), + openPrivacyPolicy: (uri) async { + openedUris.add(uri); + return true; + }, + ); + + await tester.ensureVisible(find.text('Privacy Policy')); + await tester.tap(find.text('Privacy Policy')); + await tester.pumpAndSettle(); + + expect(openedUris, [ExternalLinks.privacyPolicyUri]); + }); +} + +Future _pumpMyPage( + WidgetTester tester, { + required Locale locale, + PrivacyPolicyLauncher? openPrivacyPolicy, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: themeData, + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: BlocProvider.value( + value: _StubAuthBloc(), + child: MyPageScreen(openPrivacyPolicy: openPrivacyPolicy), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class _StubAuthBloc extends Mock implements AuthBloc { + @override + AuthState get state => const AuthState.loading(); + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; +} + +class _FakeAlarmRepository implements AlarmRepository { + @override + Future getDeviceId() => throw UnimplementedError(); + + @override + Future buildCurrentDeviceInfo() => + throw UnimplementedError(); + + @override + Future getAlarmSettings() async { + return const AlarmSettings(alarmsEnabled: false); + } + + @override + Future updateAlarmSettings({required bool alarmsEnabled}) { + throw UnimplementedError(); + } + + @override + Future registerCurrentDevice(AlarmDeviceInfo deviceInfo) { + throw UnimplementedError(); + } + + @override + Future unregisterCurrentDevice(String deviceId) { + throw UnimplementedError(); + } + + @override + Future> getAlarmWindow( + DateTime startDate, + DateTime endDate, + ) { + throw UnimplementedError(); + } + + @override + Future postAlarmStatus(AlarmStatusReport report) { + throw UnimplementedError(); + } +} + +class _FakeAlarmRegistry implements AlarmRegistryRepository { + @override + Future> loadAll() async => const []; + + @override + Future upsert(ScheduledAlarmRecord record) { + throw UnimplementedError(); + } + + @override + Future deleteByScheduleId(String scheduleId) { + throw UnimplementedError(); + } + + @override + Future deleteAll() { + throw UnimplementedError(); + } + + @override + Future replaceAll(List records) { + throw UnimplementedError(); + } +} + +class _FakeAlarmSchedulerService extends AlarmSchedulerService { + @override + Future getCapabilities() async { + return AlarmSchedulerCapabilities.unsupported; + } + + @override + Future checkPermission() async { + return AlarmPermissionState.unsupported; + } +} + +class _FakeFallbackAlarmNotificationService + implements FallbackAlarmNotificationService { + @override + Future checkPermission() async { + return AlarmPermissionState.unsupported; + } + + @override + Future requestPermission() { + throw UnimplementedError(); + } + + @override + Future scheduleFallbackAlarm(ScheduledAlarmRecord record) { + throw UnimplementedError(); + } + + @override + Future cancelFallbackAlarm(ScheduledAlarmRecord record) { + throw UnimplementedError(); + } +}