diff --git a/flutter_news_example/api/analysis_options.yaml b/flutter_news_example/api/analysis_options.yaml index 40f65cf61..09ebcd5ea 100644 --- a/flutter_news_example/api/analysis_options.yaml +++ b/flutter_news_example/api/analysis_options.yaml @@ -2,6 +2,7 @@ include: package:very_good_analysis/analysis_options.5.1.0.yaml analyzer: exclude: - build/** + - lib/**/*.g.dart linter: rules: file_names: false diff --git a/flutter_news_example/lib/app/bloc/app_bloc.dart b/flutter_news_example/lib/app/bloc/app_bloc.dart index a04b4667a..5a63b760b 100644 --- a/flutter_news_example/lib/app/bloc/app_bloc.dart +++ b/flutter_news_example/lib/app/bloc/app_bloc.dart @@ -24,6 +24,7 @@ class AppBloc extends Bloc { on(_onUserChanged); on(_onOnboardingCompleted); on(_onLogoutRequested); + on(_onDeleteAccountRequested); on(_onAppOpened); _userSubscription = _userRepository.user.listen(_userChanged); @@ -74,6 +75,22 @@ class AppBloc extends Bloc { unawaited(_userRepository.logOut()); } + Future _onDeleteAccountRequested( + AppDeleteAccountRequested event, + Emitter emit, + ) async { + try { + // We are disabling notifications when a user deletes their account + // because the user should not receive any notifications after their + // account is deleted. + unawaited(_notificationsRepository.toggleNotifications(enable: false)); + await _userRepository.deleteAccount(); + } catch (error, stackTrace) { + await _userRepository.logOut(); + addError(error, stackTrace); + } + } + Future _onAppOpened(AppOpened event, Emitter emit) async { if (state.user.isAnonymous) { final appOpenedCount = await _userRepository.fetchAppOpenedCount(); diff --git a/flutter_news_example/lib/app/bloc/app_event.dart b/flutter_news_example/lib/app/bloc/app_event.dart index 49451fda2..b73424a1c 100644 --- a/flutter_news_example/lib/app/bloc/app_event.dart +++ b/flutter_news_example/lib/app/bloc/app_event.dart @@ -11,6 +11,10 @@ class AppLogoutRequested extends AppEvent { const AppLogoutRequested(); } +class AppDeleteAccountRequested extends AppEvent { + const AppDeleteAccountRequested(); +} + class AppUserChanged extends AppEvent { const AppUserChanged(this.user); diff --git a/flutter_news_example/lib/l10n/arb/app_en.arb b/flutter_news_example/lib/l10n/arb/app_en.arb index 0c1875523..6264b5506 100644 --- a/flutter_news_example/lib/l10n/arb/app_en.arb +++ b/flutter_news_example/lib/l10n/arb/app_en.arb @@ -616,5 +616,29 @@ "description": "Text displayed on the refresh button when a network error occurs.", "type": "String", "placeholders": {} + }, + "deleteAccountDialogCancelButtonText": "Cancel", + "@deleteAccountDialogCancelButtonText": { + "description": "Delete account dialog cancel button text", + "type": "String", + "placeholders": {} + }, + "deleteAccountDialogSubtitle": "ADD YOUR DELETE DIALOG SUBTITLE", + "@deleteAccountDialogSubtitle": { + "description": "Delete account dialog subtitle", + "type": "String", + "placeholders": {} + }, + "deleteAccountDialogTitle": "ADD YOUR DELETE DIALOG TITLE", + "@deleteAccountDialogTitle": { + "description": "Delete account dialog title", + "type": "String", + "placeholders": {} + }, + "userProfileDeleteAccountButton": "Delete account", + "@userProfileDeleteAccountButton": { + "description": "User profile delete account button title", + "type": "String", + "placeholders": {} } } diff --git a/flutter_news_example/lib/user_profile/view/user_profile_page.dart b/flutter_news_example/lib/user_profile/view/user_profile_page.dart index c3fca0b1a..1d284bd6d 100644 --- a/flutter_news_example/lib/user_profile/view/user_profile_page.dart +++ b/flutter_news_example/lib/user_profile/view/user_profile_page.dart @@ -178,6 +178,22 @@ class _UserProfileViewState extends State leading: Assets.icons.aboutIcon.svg(), title: l10n.userProfileLegalAboutTitle, ), + Align( + child: AppButton.smallTransparent( + key: const Key('userProfilePage_deleteAccountButton'), + onPressed: () { + showDialog( + context: context, + builder: (_) => + const UserProfileDeleteAccountDialog(), + ); + }, + child: Text( + l10n.userProfileDeleteAccountButton, + ), + ), + ), + const SizedBox(height: AppSpacing.lg), ], ), ), diff --git a/flutter_news_example/lib/user_profile/widgets/user_profile_delete_account_dialog.dart b/flutter_news_example/lib/user_profile/widgets/user_profile_delete_account_dialog.dart new file mode 100644 index 000000000..817c2ab97 --- /dev/null +++ b/flutter_news_example/lib/user_profile/widgets/user_profile_delete_account_dialog.dart @@ -0,0 +1,42 @@ +import 'package:app_ui/app_ui.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_example/app/app.dart'; +import 'package:flutter_news_example/l10n/l10n.dart'; + +class UserProfileDeleteAccountDialog extends StatelessWidget { + const UserProfileDeleteAccountDialog({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text( + l10n.deleteAccountDialogTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + content: Text( + l10n.deleteAccountDialogSubtitle, + style: Theme.of(context).textTheme.bodyMedium, + ), + actions: [ + AppButton.smallDarkAqua( + key: const Key('userProfilePage_cancelDeleteAccountButton'), + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(l10n.deleteAccountDialogCancelButtonText), + ), + AppButton.smallRedWine( + key: const Key('userProfilePage_deleteAccountButton'), + onPressed: () { + context.read().add(const AppDeleteAccountRequested()); + Navigator.of(context).pop(); + }, + child: Text(l10n.userProfileDeleteAccountButton), + ), + ], + ); + } +} diff --git a/flutter_news_example/lib/user_profile/widgets/widgets.dart b/flutter_news_example/lib/user_profile/widgets/widgets.dart index a74f7e69b..d5202c08a 100644 --- a/flutter_news_example/lib/user_profile/widgets/widgets.dart +++ b/flutter_news_example/lib/user_profile/widgets/widgets.dart @@ -1,2 +1,3 @@ export 'user_profile_button.dart'; +export 'user_profile_delete_account_dialog.dart'; export 'user_profile_subscribe_box.dart'; diff --git a/flutter_news_example/packages/authentication_client/authentication_client/lib/src/authentication_client.dart b/flutter_news_example/packages/authentication_client/authentication_client/lib/src/authentication_client.dart index 18fab6f9a..e719f7f39 100644 --- a/flutter_news_example/packages/authentication_client/authentication_client/lib/src/authentication_client.dart +++ b/flutter_news_example/packages/authentication_client/authentication_client/lib/src/authentication_client.dart @@ -101,6 +101,14 @@ class LogOutFailure extends AuthenticationException { const LogOutFailure(super.error); } +/// {@template delete_account_failure} +/// Thrown during the delete account process if a failure occurs. +/// {@endtemplate} +class DeleteAccountFailure extends AuthenticationException { + /// {@macro delete_account_failure} + const DeleteAccountFailure(super.error); +} + /// A generic Authentication Client Interface. abstract class AuthenticationClient { /// Stream of [AuthenticationUser] which will emit the current user when @@ -160,4 +168,9 @@ abstract class AuthenticationClient { /// /// Throws a [LogOutFailure] if an exception occurs. Future logOut(); + + /// Deletes the current user account. + /// + /// Throws a [DeleteAccountFailure] if an exception occurs. + Future deleteAccount(); } diff --git a/flutter_news_example/packages/authentication_client/authentication_client/test/src/authentication_client_test.dart b/flutter_news_example/packages/authentication_client/authentication_client/test/src/authentication_client_test.dart index 368952451..fcb12a8bf 100644 --- a/flutter_news_example/packages/authentication_client/authentication_client/test/src/authentication_client_test.dart +++ b/flutter_news_example/packages/authentication_client/authentication_client/test/src/authentication_client_test.dart @@ -91,4 +91,11 @@ void main() { returnsNormally, ); }); + + test('exports DeleteAccountFailure', () { + expect( + () => DeleteAccountFailure('oops'), + returnsNormally, + ); + }); } diff --git a/flutter_news_example/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart b/flutter_news_example/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart index 0feab75f3..1f8a6457c 100644 --- a/flutter_news_example/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart +++ b/flutter_news_example/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart @@ -271,6 +271,23 @@ class FirebaseAuthenticationClient implements AuthenticationClient { } } + /// Deletes and signs out the user. + @override + Future deleteAccount() async { + try { + final user = _firebaseAuth.currentUser; + if (user == null) { + throw DeleteAccountFailure( + Exception('User is not authenticated'), + ); + } + + await user.delete(); + } catch (error, stackTrace) { + Error.throwWithStackTrace(DeleteAccountFailure(error), stackTrace); + } + } + /// Updates the user token in [TokenStorage] if the user is authenticated. Future _onUserChanged(AuthenticationUser user) async { if (!user.isAnonymous) { diff --git a/flutter_news_example/packages/authentication_client/firebase_authentication_client/test/firebase_authentication_client_test.dart b/flutter_news_example/packages/authentication_client/firebase_authentication_client/test/firebase_authentication_client_test.dart index 0bff33202..0a265499c 100644 --- a/flutter_news_example/packages/authentication_client/firebase_authentication_client/test/firebase_authentication_client_test.dart +++ b/flutter_news_example/packages/authentication_client/firebase_authentication_client/test/firebase_authentication_client_test.dart @@ -596,6 +596,38 @@ void main() { }); }); + group('deleteAccount', () { + test('calls deleteAccount', () async { + final firebaseUser = MockFirebaseUser(); + when(firebaseUser.delete).thenAnswer((_) async {}); + when(() => firebaseAuth.currentUser).thenReturn(firebaseUser); + + await firebaseAuthenticationClient.deleteAccount(); + verify(() => firebaseAuth.currentUser).called(1); + verify(firebaseUser.delete).called(1); + }); + + test('throws DeleteAccountFailure if current user is null', () async { + when(() => firebaseAuth.currentUser).thenReturn(null); + + expect( + firebaseAuthenticationClient.deleteAccount(), + throwsA(isA()), + ); + }); + + test('throws DeleteAccountFailure when deleteAccount throws', () async { + final firebaseUser = MockFirebaseUser(); + when(firebaseUser.delete).thenThrow(Exception()); + when(() => firebaseAuth.currentUser).thenReturn(firebaseUser); + + expect( + firebaseAuthenticationClient.deleteAccount(), + throwsA(isA()), + ); + }); + }); + group('user', () { const userId = 'mock-uid'; const email = 'mock-email'; diff --git a/flutter_news_example/packages/user_repository/lib/src/user_repository.dart b/flutter_news_example/packages/user_repository/lib/src/user_repository.dart index 463d7bbe6..cc3228f2d 100644 --- a/flutter_news_example/packages/user_repository/lib/src/user_repository.dart +++ b/flutter_news_example/packages/user_repository/lib/src/user_repository.dart @@ -212,6 +212,17 @@ class UserRepository { } } + /// Deletes the current user account. + Future deleteAccount() async { + try { + await _authenticationClient.deleteAccount(); + } on DeleteAccountFailure { + rethrow; + } catch (error, stackTrace) { + Error.throwWithStackTrace(DeleteAccountFailure(error), stackTrace); + } + } + /// Returns the number of times the app was opened. Future fetchAppOpenedCount() async { try { diff --git a/flutter_news_example/packages/user_repository/test/src/user_repository_test.dart b/flutter_news_example/packages/user_repository/test/src/user_repository_test.dart index 4c7dde965..aa7910933 100644 --- a/flutter_news_example/packages/user_repository/test/src/user_repository_test.dart +++ b/flutter_news_example/packages/user_repository/test/src/user_repository_test.dart @@ -45,6 +45,8 @@ class FakeLogInWithFacebookCanceled extends Fake class FakeLogOutFailure extends Fake implements LogOutFailure {} +class FakeDeleteAccountFailure extends Fake implements DeleteAccountFailure {} + class FakeSendLoginEmailLinkFailure extends Fake implements SendLoginEmailLinkFailure {} @@ -419,6 +421,29 @@ void main() { }); }); + group('deleteAccount', () { + test('calls logOut on AuthenticationClient', () async { + when(() => authenticationClient.deleteAccount()) + .thenAnswer((_) async {}); + await userRepository.deleteAccount(); + verify(() => authenticationClient.deleteAccount()).called(1); + }); + + test('rethrows DeleteAccountFailure', () async { + final exception = FakeDeleteAccountFailure(); + when(() => authenticationClient.deleteAccount()).thenThrow(exception); + expect(() => userRepository.deleteAccount(), throwsA(exception)); + }); + + test('throws DeleteAccountFailure on generic exception', () async { + when(() => authenticationClient.deleteAccount()).thenThrow(Exception()); + expect( + () => userRepository.deleteAccount(), + throwsA(isA()), + ); + }); + }); + group('UserFailure', () { final error = Exception('errorMessage'); diff --git a/flutter_news_example/test/app/bloc/app_bloc_test.dart b/flutter_news_example/test/app/bloc/app_bloc_test.dart index 868261176..950fb97dd 100644 --- a/flutter_news_example/test/app/bloc/app_bloc_test.dart +++ b/flutter_news_example/test/app/bloc/app_bloc_test.dart @@ -241,6 +241,64 @@ void main() { ); }); + group('AppDeleteAccountRequested', () { + setUp(() { + when( + () => notificationsRepository.toggleNotifications( + enable: any(named: 'enable'), + ), + ).thenAnswer((_) async {}); + + when(() => userRepository.deleteAccount()).thenAnswer((_) async {}); + when(() => userRepository.logOut()).thenAnswer((_) async {}); + }); + + blocTest( + 'calls toggleNotifications off on NotificationsRepository', + build: () => AppBloc( + userRepository: userRepository, + notificationsRepository: notificationsRepository, + user: user, + ), + act: (bloc) => bloc.add(AppDeleteAccountRequested()), + verify: (_) { + verify( + () => notificationsRepository.toggleNotifications(enable: false), + ).called(1); + }, + ); + + blocTest( + 'calls deleteAccount on UserRepository', + build: () => AppBloc( + userRepository: userRepository, + notificationsRepository: notificationsRepository, + user: user, + ), + act: (bloc) => bloc.add(AppDeleteAccountRequested()), + verify: (_) { + verify(() => userRepository.deleteAccount()).called(1); + }, + ); + + blocTest( + 'calls logOut when deleteAccount on UserRepository fails', + setUp: () { + when(() => userRepository.deleteAccount()).thenThrow(Exception()); + }, + build: () => AppBloc( + userRepository: userRepository, + notificationsRepository: notificationsRepository, + user: user, + ), + act: (bloc) => bloc.add(AppDeleteAccountRequested()), + verify: (_) { + verify(() => userRepository.deleteAccount()).called(1); + verify(() => userRepository.logOut()).called(1); + }, + ); + }); + group('close', () { late StreamController userController; diff --git a/flutter_news_example/test/app/bloc/app_event_test.dart b/flutter_news_example/test/app/bloc/app_event_test.dart index 64cfd756e..1270e5ec2 100644 --- a/flutter_news_example/test/app/bloc/app_event_test.dart +++ b/flutter_news_example/test/app/bloc/app_event_test.dart @@ -37,6 +37,15 @@ void main() { }); }); + group('AppDeleteAccountRequested', () { + test('supports value comparisons', () { + expect( + AppDeleteAccountRequested(), + AppDeleteAccountRequested(), + ); + }); + }); + group('AppOpened', () { test('supports value comparisons', () { expect( diff --git a/flutter_news_example/test/user_profile/view/user_profile_page_test.dart b/flutter_news_example/test/user_profile/view/user_profile_page_test.dart index b64458e68..054a1496a 100644 --- a/flutter_news_example/test/user_profile/view/user_profile_page_test.dart +++ b/flutter_news_example/test/user_profile/view/user_profile_page_test.dart @@ -537,6 +537,36 @@ void main() { expect(find.byType(NotificationPreferencesPage), findsOneWidget); }); }); + + group('shows', () { + testWidgets( + 'UserProfileDeleteAccountDialog ' + 'when tapped on Delete account', (tester) async { + await tester.pumpApp( + BlocProvider.value( + value: userProfileBloc, + child: UserProfileView(), + ), + ); + + final deleteAccountButton = find.byKey( + Key('userProfilePage_deleteAccountButton'), + ); + await tester.dragUntilVisible( + deleteAccountButton, + find.byType(UserProfileView), + Offset(0, -100), + duration: Duration.zero, + ); + await tester.pumpAndSettle(); + + await tester.ensureVisible(deleteAccountButton); + await tester.tap(deleteAccountButton); + await tester.pumpAndSettle(); + + expect(find.byType(UserProfileDeleteAccountDialog), findsOneWidget); + }); + }); }); }); } diff --git a/flutter_news_example/test/user_profile/widgets/user_profile_delete_account_dialog_test.dart b/flutter_news_example/test/user_profile/widgets/user_profile_delete_account_dialog_test.dart new file mode 100644 index 000000000..5106887ad --- /dev/null +++ b/flutter_news_example/test/user_profile/widgets/user_profile_delete_account_dialog_test.dart @@ -0,0 +1,72 @@ +// ignore_for_file: prefer_const_constructors +import 'package:flutter/material.dart'; +import 'package:flutter_news_example/app/app.dart'; +import 'package:flutter_news_example/user_profile/user_profile.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('UserProfileDeleteAccountDialog', () { + late AppBloc appBloc; + + final cancelButton = find.byKey( + const Key('userProfilePage_cancelDeleteAccountButton'), + ); + final deleteAccountButton = find.byKey( + const Key('userProfilePage_deleteAccountButton'), + ); + + setUp(() { + appBloc = MockAppBloc(); + }); + + testWidgets('renders cancel and delete account buttons', (tester) async { + await tester.pumpApp( + UserProfileDeleteAccountDialog(), + appBloc: appBloc, + ); + + expect(cancelButton, findsOneWidget); + expect(deleteAccountButton, findsOneWidget); + }); + + testWidgets('closes dialog when cancel button is pressed', (tester) async { + final navigator = MockNavigator(); + when(navigator.pop).thenAnswer((_) async {}); + + await tester.pumpApp( + Scaffold(body: UserProfileDeleteAccountDialog()), + appBloc: appBloc, + navigator: navigator, + ); + + expect(cancelButton, findsOneWidget); + await tester.pumpAndSettle(); + await tester.tap(cancelButton); + + verify(navigator.pop).called(1); + }); + + testWidgets( + 'adds AppDeleteAccountRequested to AppBloc and closes dialog ' + 'when delete account button is pressed', (tester) async { + final navigator = MockNavigator(); + when(navigator.pop).thenAnswer((_) async {}); + + await tester.pumpApp( + Scaffold(body: UserProfileDeleteAccountDialog()), + appBloc: appBloc, + navigator: navigator, + ); + + expect(cancelButton, findsOneWidget); + await tester.pumpAndSettle(); + await tester.tap(deleteAccountButton); + + verify(() => appBloc.add(const AppDeleteAccountRequested())).called(1); + verify(navigator.pop).called(1); + }); + }); +}