Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/core/constants/external_links.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
final class ExternalLinks {
static final privacyPolicyUri = Uri.parse(
'https://ontime-back.duckdns.org/privacy-policy',
);
}
8 changes: 8 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/app_ko.arb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
"accountSettings": "계정 설정",
"editDefaultPreparation": "기본 준비과정 / 여유시간 수정",
"allowAppNotifications": "앱 알림 허용",
"privacyPolicy": "개인정보 처리방침",
"privacyPolicyOpenError": "개인정보 처리방침을 열 수 없습니다. 잠시 후 다시 시도해주세요.",
"logOut": "로그아웃",
"deleteAccount": "회원 탈퇴",
"editSpareTime": "여유시간 수정",
Expand Down
12 changes: 12 additions & 0 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations_ko.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get allowAppNotifications => '앱 알림 허용';

@override
String get privacyPolicy => '개인정보 처리방침';

@override
String get privacyPolicyOpenError => '개인정보 처리방침을 열 수 없습니다. 잠시 후 다시 시도해주세요.';

@override
String get logOut => '로그아웃';

Expand Down
49 changes: 48 additions & 1 deletion lib/presentation/my_page/my_page_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<bool> 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) {
Expand Down Expand Up @@ -91,6 +98,15 @@ class MyPageScreen extends StatelessWidget {
await _handleNotificationPermission(context);
},
),
_SettingTile(
title: AppLocalizations.of(context)!.privacyPolicy,
onTap: () async {
await _handlePrivacyPolicyTap(
context,
_openPrivacyPolicy ?? _openPrivacyPolicyExternally,
);
},
),
],
),
),
Expand All @@ -101,6 +117,37 @@ class MyPageScreen extends StatelessWidget {
}
}

Future<bool> _openPrivacyPolicyExternally(Uri uri) {
return launchUrl(uri, mode: LaunchMode.externalApplication);
}

Future<void> _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();

Expand Down
199 changes: 199 additions & 0 deletions test/presentation/my_page/my_page_screen_test.dart
Original file line number Diff line number Diff line change
@@ -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<AlarmRepository>(_FakeAlarmRepository())
..registerSingleton<AlarmRegistryRepository>(_FakeAlarmRegistry())
..registerSingleton<AlarmSchedulerService>(_FakeAlarmSchedulerService())
..registerSingleton<FallbackAlarmNotificationService>(
_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 = <Uri>[];

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<void> _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<AuthBloc>.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<AuthState> get stream => const Stream.empty();

@override
bool get isClosed => false;
}

class _FakeAlarmRepository implements AlarmRepository {
@override
Future<String> getDeviceId() => throw UnimplementedError();

@override
Future<AlarmDeviceInfo> buildCurrentDeviceInfo() =>
throw UnimplementedError();

@override
Future<AlarmSettings> getAlarmSettings() async {
return const AlarmSettings(alarmsEnabled: false);
}

@override
Future<AlarmSettings> updateAlarmSettings({required bool alarmsEnabled}) {
throw UnimplementedError();
}

@override
Future<void> registerCurrentDevice(AlarmDeviceInfo deviceInfo) {
throw UnimplementedError();
}

@override
Future<void> unregisterCurrentDevice(String deviceId) {
throw UnimplementedError();
}

@override
Future<List<ScheduleWithPreparationEntity>> getAlarmWindow(
DateTime startDate,
DateTime endDate,
) {
throw UnimplementedError();
}

@override
Future<void> postAlarmStatus(AlarmStatusReport report) {
throw UnimplementedError();
}
}

class _FakeAlarmRegistry implements AlarmRegistryRepository {
@override
Future<List<ScheduledAlarmRecord>> loadAll() async => const [];

@override
Future<void> upsert(ScheduledAlarmRecord record) {
throw UnimplementedError();
}

@override
Future<void> deleteByScheduleId(String scheduleId) {
throw UnimplementedError();
}

@override
Future<void> deleteAll() {
throw UnimplementedError();
}

@override
Future<void> replaceAll(List<ScheduledAlarmRecord> records) {
throw UnimplementedError();
}
}

class _FakeAlarmSchedulerService extends AlarmSchedulerService {
@override
Future<AlarmSchedulerCapabilities> getCapabilities() async {
return AlarmSchedulerCapabilities.unsupported;
}

@override
Future<AlarmPermissionState> checkPermission() async {
return AlarmPermissionState.unsupported;
}
}

class _FakeFallbackAlarmNotificationService
implements FallbackAlarmNotificationService {
@override
Future<AlarmPermissionState> checkPermission() async {
return AlarmPermissionState.unsupported;
}

@override
Future<AlarmPermissionState> requestPermission() {
throw UnimplementedError();
}

@override
Future<void> scheduleFallbackAlarm(ScheduledAlarmRecord record) {
throw UnimplementedError();
}

@override
Future<void> cancelFallbackAlarm(ScheduledAlarmRecord record) {
throw UnimplementedError();
}
}
Loading