From 9da71c3186d541c947833436fd3f5c5717b6288f Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 3 Sep 2024 16:58:38 +0800 Subject: [PATCH] feat: support account deletion (#6103) * feat: support account deletion * chore: update translation * feat: support account deletion on mobile * fix: only display account deletion button in appflowy cloud mode * chore: remove unused code * chore: update checkbox style * fix: integration test * chore: update translations * chore: update sentry version in podfile.lock * chore: update version --- frontend/Makefile.toml | 2 +- .../cloud/anon_user_continue_test.dart | 12 +- .../cloud/appflowy_cloud_auth_test.dart | 9 +- .../cloud/user_setting_sync_test.dart | 6 +- .../shared/auth_operation.dart | 6 +- .../integration_test/shared/settings.dart | 6 +- frontend/appflowy_flutter/ios/Podfile.lock | 10 +- .../home/mobile_home_page_header.dart | 4 +- .../home/mobile_home_setting_page.dart | 1 + .../home/setting/settings_popup_menu.dart | 23 +- .../setting/user_session_setting_group.dart | 227 ++++++++-- .../lib/user/application/user_service.dart | 8 +- .../widgets/sign_in_or_logout_button.dart | 15 +- .../menu/sidebar/space/shared_widget.dart | 10 +- .../settings/pages/account/account.dart | 3 + .../pages/account/account_deletion.dart | 262 ++++++++++++ .../pages/account/account_sign_in_out.dart | 185 +++++++++ .../pages/account/account_user_profile.dart | 179 ++++++++ .../settings/pages/settings_account_view.dart | 392 +----------------- .../settings/widgets/_restart_app_button.dart | 4 +- .../presentation/widgets/dialogs.dart | 32 ++ frontend/appflowy_flutter/pubspec.yaml | 2 +- frontend/resources/translations/en.json | 9 +- 23 files changed, 936 insertions(+), 471 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 63e451b09d..bfcd639cb2 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.6.8" +APPFLOWY_VERSION = "0.6.9" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart index 39ef5386e4..00a02788da 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/anon_user_continue_test.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'dart:ui'; -import 'package:flutter/material.dart'; - import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -12,6 +10,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; @@ -19,6 +18,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:intl/intl.dart'; @@ -51,12 +51,12 @@ void main() { // Scroll to sign-in await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); // sign up with Google await tester.tapGoogleLoginInButton(); @@ -68,7 +68,7 @@ void main() { // Scroll to sign-out await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); @@ -85,7 +85,7 @@ void main() { await tester.openSettings(); await tester.openSettingsPage(SettingsPage.account); final userNameInput = - tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting; + tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; expect(userNameInput.name, 'Me'); }); }); diff --git a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart index 395e4ed24a..dacc900103 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/appflowy_cloud_auth_test.dart @@ -6,6 +6,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -41,11 +42,11 @@ void main() { // Scroll to sign-out await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeText(LocaleKeys.button_ok.tr()); await tester.tapButtonWithName(LocaleKeys.button_ok.tr()); @@ -67,11 +68,11 @@ void main() { // Scroll to sign-in await tester.scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: find.findSettingsScrollable(), ); - await tester.tapButton(find.byType(SignInOutButton)); + await tester.tapButton(find.byType(AccountSignInOutButton)); tester.expectToSeeGoogleLoginButton(); }); diff --git a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart index d0377908c3..253d533607 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/user_setting_sync_test.dart @@ -2,8 +2,6 @@ import 'dart:io'; -import 'package:flutter/material.dart'; - import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -11,6 +9,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; @@ -18,6 +17,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; @@ -67,7 +67,7 @@ void main() { // Verify name final profileSetting = - tester.widget(find.byType(UserProfileSetting)) as UserProfileSetting; + tester.widget(find.byType(AccountUserProfile)) as AccountUserProfile; expect(profileSetting.name, name); }); diff --git a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart index 51df6d6b14..1f2f23dc2c 100644 --- a/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart +++ b/frontend/appflowy_flutter/integration_test/shared/auth_operation.dart @@ -1,6 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -20,12 +20,12 @@ extension AppFlowyAuthTest on WidgetTester { Future logout() async { final scrollable = find.findSettingsScrollable(); await scrollUntilVisible( - find.byType(SignInOutButton), + find.byType(AccountSignInOutButton), 100, scrollable: scrollable, ); - await tapButton(find.byType(SignInOutButton)); + await tapButton(find.byType(AccountSignInOutButton)); expectToSeeText(LocaleKeys.button_ok.tr()); await tapButtonWithName(LocaleKeys.button_ok.tr()); diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart index 20193dfd9b..e06634efef 100644 --- a/frontend/appflowy_flutter/integration_test/shared/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -2,7 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_setting.dart'; -import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; @@ -77,14 +77,14 @@ extension AppFlowySettings on WidgetTester { Future enterUserName(String name) async { // Enable editing username final editUsernameFinder = find.descendant( - of: find.byType(UserProfileSetting), + of: find.byType(AccountUserProfile), matching: find.byFlowySvg(FlowySvgs.edit_s), ); await tap(editUsernameFinder, warnIfMissed: false); await pumpAndSettle(); final userNameFinder = find.descendant( - of: find.byType(UserProfileSetting), + of: find.byType(AccountUserProfile), matching: find.byType(FlowyTextField), ); await enterText(userNameFinder, name); diff --git a/frontend/appflowy_flutter/ios/Podfile.lock b/frontend/appflowy_flutter/ios/Podfile.lock index 8829c71074..28d37bfa23 100644 --- a/frontend/appflowy_flutter/ios/Podfile.lock +++ b/frontend/appflowy_flutter/ios/Podfile.lock @@ -67,11 +67,11 @@ PODS: - SDWebImage (5.14.2): - SDWebImage/Core (= 5.14.2) - SDWebImage/Core (5.14.2) - - Sentry/HybridSDK (8.33.0) - - sentry_flutter (8.7.0): + - Sentry/HybridSDK (8.35.1) + - sentry_flutter (8.8.0): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.33.0) + - Sentry/HybridSDK (= 8.35.1) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -184,8 +184,8 @@ SPEC CHECKSUMS: permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 - Sentry: 8560050221424aef0bebc8e31eedf00af80f90a6 - sentry_flutter: e26b861f744e5037a3faf9bf56603ec65d658a61 + Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 + sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 8742dce817..03d444e0b9 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -46,7 +46,9 @@ class MobileHomePageHeader extends StatelessWidget { ? _MobileWorkspace(userProfile: userProfile) : _MobileUser(userProfile: userProfile), ), - const HomePageSettingsPopupMenu(), + HomePageSettingsPopupMenu( + userProfile: userProfile, + ), const HSpace(8.0), ], ), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart index 07ee4de7d6..1e0ddb5a51 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_setting_page.dart @@ -87,6 +87,7 @@ class _MobileHomeSettingPageState extends State { const SupportSettingGroup(), const AboutSettingGroup(), UserSessionSettingGroup( + userProfile: userProfile, showThirdPartyLogin: showThirdPartyLogin, ), const VSpace(20), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart index ac7dfa17e6..f5afc10465 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/setting/settings_popup_menu.dart @@ -4,6 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart'; import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart' @@ -18,7 +19,12 @@ enum _MobileSettingsPopupMenuItem { } class HomePageSettingsPopupMenu extends StatelessWidget { - const HomePageSettingsPopupMenu({super.key}); + const HomePageSettingsPopupMenu({ + super.key, + required this.userProfile, + }); + + final UserProfilePB userProfile; @override Widget build(BuildContext context) { @@ -41,12 +47,15 @@ class HomePageSettingsPopupMenu extends StatelessWidget { svg: FlowySvgs.m_notification_settings_s, text: LocaleKeys.settings_popupMenuItem_settings.tr(), ), - const PopupMenuDivider(height: 0.5), - _buildItem( - value: _MobileSettingsPopupMenuItem.members, - svg: FlowySvgs.m_settings_member_s, - text: LocaleKeys.settings_popupMenuItem_members.tr(), - ), + // only show the member items in cloud mode + if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + const PopupMenuDivider(height: 0.5), + _buildItem( + value: _MobileSettingsPopupMenuItem.members, + svg: FlowySvgs.m_settings_member_s, + text: LocaleKeys.settings_popupMenuItem_members.tr(), + ), + ], const PopupMenuDivider(height: 0.5), _buildItem( value: _MobileSettingsPopupMenuItem.trash, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart index 1d91c3b9f6..797c97ca74 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/user_session_setting_group.dart @@ -1,10 +1,14 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; @@ -13,60 +17,199 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class UserSessionSettingGroup extends StatelessWidget { const UserSessionSettingGroup({ super.key, + required this.userProfile, required this.showThirdPartyLogin, }); + final UserProfilePB userProfile; final bool showThirdPartyLogin; @override Widget build(BuildContext context) { return Column( children: [ - if (showThirdPartyLogin) ...[ - BlocProvider( - create: (context) => getIt(), - child: BlocConsumer( - listener: (context, state) { - state.successOrFail?.fold( - (result) => runAppFlowy(), - (e) => Log.error(e), - ); - }, - builder: (context, state) { - return const ThirdPartySignInButtons( - expanded: true, - ); - }, + // third party sign in buttons + if (showThirdPartyLogin) _buildThirdPartySignInButtons(context), + const VSpace(8.0), + + // logout button + MobileLogoutButton( + text: LocaleKeys.settings_menu_logout.tr(), + onPressed: () async => _showLogoutDialog(), + ), + + // delete account button + // only show the delete account button in cloud mode + if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[ + const VSpace(16.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => _showDeleteAccountDialog(context), + ), + ], + ], + ); + } + + Widget _buildThirdPartySignInButtons(BuildContext context) { + return BlocProvider( + create: (context) => getIt(), + child: BlocConsumer( + listener: (context, state) { + state.successOrFail?.fold( + (result) => runAppFlowy(), + (e) => Log.error(e), + ); + }, + builder: (context, state) { + return const ThirdPartySignInButtons( + expanded: true, + ); + }, + ), + ); + } + + Future _showDeleteAccountDialog(BuildContext context) async { + return showMobileBottomSheet( + context, + useRootNavigator: true, + backgroundColor: Theme.of(context).colorScheme.surface, + builder: (_) => const _DeleteAccountBottomSheet(), + ); + } + + Future _showLogoutDialog() async { + return showFlowyCupertinoConfirmDialog( + title: LocaleKeys.settings_menu_logoutPrompt.tr(), + leftButton: FlowyText( + LocaleKeys.button_cancel.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w500, + color: const Color(0xFF007AFF), + ), + rightButton: FlowyText( + LocaleKeys.button_logout.tr(), + fontSize: 17.0, + figmaLineHeight: 24.0, + fontWeight: FontWeight.w400, + color: const Color(0xFFFE0220), + ), + onRightButtonPressed: (context) async { + Navigator.of(context).pop(); + await getIt().signOut(); + await runAppFlowy(); + }, + ); + } +} + +class _DeleteAccountBottomSheet extends StatefulWidget { + const _DeleteAccountBottomSheet(); + + @override + State<_DeleteAccountBottomSheet> createState() => + _DeleteAccountBottomSheetState(); +} + +class _DeleteAccountBottomSheetState extends State<_DeleteAccountBottomSheet> { + final emailController = TextEditingController(); + final isChecked = ValueNotifier(false); + + @override + void dispose() { + emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VSpace(18.0), + const FlowySvg( + FlowySvgs.icon_warning_xl, + blendMode: null, + ), + const VSpace(12.0), + FlowyText( + LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), + fontSize: 20.0, + fontWeight: FontWeight.w500, + ), + const VSpace(12.0), + FlowyText( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w400, + maxLines: 10, + ), + const VSpace(18.0), + SizedBox( + height: 36.0, + child: FlowyTextField( + controller: emailController, + textStyle: const TextStyle(fontSize: 14.0), + hintStyle: const TextStyle(fontSize: 14.0), + hintText: LocaleKeys.settings_user_email.tr(), ), ), - const VSpace(8), + const VSpace(18.0), + _buildCheckbox(), + const VSpace(18.0), + MobileLogoutButton( + text: LocaleKeys.button_deleteAccount.tr(), + textColor: Theme.of(context).colorScheme.error, + onPressed: () => deleteMyAccount( + context, + emailController.text.trim(), + isChecked.value, + ), + ), + const VSpace(12.0), + MobileLogoutButton( + text: LocaleKeys.button_cancel.tr(), + onPressed: () => Navigator.of(context).pop(), + ), + const VSpace(36.0), ], - MobileSignInOrLogoutButton( - labelText: LocaleKeys.settings_menu_logout.tr(), - onPressed: () async { - await showFlowyCupertinoConfirmDialog( - title: LocaleKeys.settings_menu_logoutPrompt.tr(), - leftButton: FlowyText( - LocaleKeys.button_cancel.tr(), - fontSize: 17.0, - figmaLineHeight: 24.0, - fontWeight: FontWeight.w500, - color: const Color(0xFF007AFF), - ), - rightButton: FlowyText( - LocaleKeys.button_logout.tr(), - fontSize: 17.0, - figmaLineHeight: 24.0, - fontWeight: FontWeight.w400, - color: const Color(0xFFFE0220), - ), - onRightButtonPressed: (context) async { - Navigator.of(context).pop(); - await getIt().signOut(); - await runAppFlowy(); - }, - ); - }, + ), + ); + } + + Widget _buildCheckbox() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => isChecked.value = !isChecked.value, + child: ValueListenableBuilder( + valueListenable: isChecked, + builder: (context, isChecked, _) { + return Padding( + padding: const EdgeInsets.all(1.0), + child: FlowySvg( + isChecked ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + size: const Size.square(16.0), + blendMode: isChecked ? null : BlendMode.srcIn, + ), + ); + }, + ), + ), + const HSpace(6.0), + Expanded( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint2.tr(), + fontSize: 14.0, + figmaLineHeight: 18.0, + maxLines: 3, + ), ), ], ); diff --git a/frontend/appflowy_flutter/lib/user/application/user_service.dart b/frontend/appflowy_flutter/lib/user/application/user_service.dart index cbec823539..5a75a4df3e 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_service.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; - import 'package:appflowy/workspace/application/settings/plan/workspace_subscription_ext.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; @@ -9,6 +7,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; abstract class IUserBackendService { Future> cancelSubscription( @@ -292,4 +291,9 @@ class UserBackendService implements IUserBackendService { return UserEventUpdateWorkspaceSubscriptionPaymentPeriod(request).send(); } + + // NOTE: This function is irreversible and will delete the current user's account. + static Future> deleteCurrentAccount() { + return UserEventDeleteAccount().send(); + } } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart index 58509aee5a..5146e29962 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/widgets/sign_in_or_logout_button.dart @@ -2,16 +2,18 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -class MobileSignInOrLogoutButton extends StatelessWidget { - const MobileSignInOrLogoutButton({ +class MobileLogoutButton extends StatelessWidget { + const MobileLogoutButton({ super.key, this.icon, - required this.labelText, + required this.text, + this.textColor, required this.onPressed, }); final FlowySvgData? icon; - final String labelText; + final String text; + final Color? textColor; final VoidCallback onPressed; @override @@ -26,7 +28,7 @@ class MobileSignInOrLogoutButton extends StatelessWidget { Radius.circular(4), ), border: Border.all( - color: style.colorScheme.outline, + color: textColor ?? style.colorScheme.outline, width: 0.5, ), ), @@ -52,9 +54,10 @@ class MobileSignInOrLogoutButton extends StatelessWidget { const HSpace(8), ], FlowyText( - labelText, + text, fontSize: 14.0, fontWeight: FontWeight.w400, + color: textColor, ), ], ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart index 0f3bebc7e8..586120000a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/shared_widget.dart @@ -334,8 +334,10 @@ class _ConfirmPopupState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(), - const VSpace(6), - _buildDescription(), + if (widget.description.isNotEmpty) ...[ + const VSpace(6), + _buildDescription(), + ], if (widget.child != null) ...[ const VSpace(12), widget.child!, @@ -376,6 +378,10 @@ class _ConfirmPopupState extends State { } Widget _buildDescription() { + if (widget.description.isEmpty) { + return const SizedBox.shrink(); + } + return FlowyText.regular( widget.description, fontSize: 16.0, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart new file mode 100644 index 0000000000..7b337d8d78 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account.dart @@ -0,0 +1,3 @@ +export 'account_deletion.dart'; +export 'account_sign_in_out.dart'; +export 'account_user_profile.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart new file mode 100644 index 0000000000..afb078bff8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_deletion.dart @@ -0,0 +1,262 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart' show PlatformExtension; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:toastification/toastification.dart'; + +class AccountDeletionButton extends StatefulWidget { + const AccountDeletionButton({ + super.key, + }); + + @override + State createState() => _AccountDeletionButtonState(); +} + +class _AccountDeletionButtonState extends State { + final TextEditingController emailController = TextEditingController(); + final isCheckedNotifier = ValueNotifier(false); + + @override + void dispose() { + emailController.dispose(); + isCheckedNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).brightness == Brightness.light + ? const Color(0xFF4F4F4F) + : const Color(0xFFB0B0B0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText( + LocaleKeys.button_deleteAccount.tr(), + fontSize: 14.0, + fontWeight: FontWeight.w500, + figmaLineHeight: 21.0, + color: textColor, + ), + const VSpace(8), + Row( + children: [ + Flexible( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_description.tr(), + fontSize: 12.0, + figmaLineHeight: 13.0, + maxLines: 2, + color: textColor, + ), + ), + const HSpace(32), + FlowyTextButton( + LocaleKeys.button_deleteAccount.tr(), + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 10), + fillColor: Colors.transparent, + radius: Corners.s12Border, + hoverColor: Theme.of(context).colorScheme.error.withOpacity(0.1), + fontColor: Theme.of(context).colorScheme.error, + fontHoverColor: Colors.white, + fontSize: 12, + isDangerous: true, + lineHeight: 18.0 / 12.0, + onPressed: () { + isCheckedNotifier.value = false; + + showCancelAndDeleteDialog( + context: context, + title: + LocaleKeys.newSettings_myAccount_deleteAccount_title.tr(), + description: '', + builder: (_) => _AccountDeletionDialog( + emailController: emailController, + isChecked: isCheckedNotifier, + ), + onDelete: () => deleteMyAccount( + context, + emailController.text.trim(), + isCheckedNotifier.value, + ), + ); + }, + ), + ], + ), + ], + ); + } +} + +class _AccountDeletionDialog extends StatelessWidget { + const _AccountDeletionDialog({ + required this.emailController, + required this.isChecked, + }); + + final TextEditingController emailController; + final ValueNotifier isChecked; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint1.tr(), + fontSize: 14.0, + figmaLineHeight: 18.0, + maxLines: 2, + color: ConfirmPopupColor.descriptionColor(context), + ), + const VSpace(12.0), + FlowyTextField( + hintText: LocaleKeys.settings_user_email.tr(), + controller: emailController, + ), + const VSpace(16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => isChecked.value = !isChecked.value, + child: ValueListenableBuilder( + valueListenable: isChecked, + builder: (context, isChecked, _) { + return FlowySvg( + isChecked ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s, + size: const Size.square(16.0), + blendMode: isChecked ? null : BlendMode.srcIn, + ); + }, + ), + ), + const HSpace(6.0), + Expanded( + child: FlowyText.regular( + LocaleKeys.newSettings_myAccount_deleteAccount_confirmHint2 + .tr(), + fontSize: 14.0, + figmaLineHeight: 16.0, + maxLines: 3, + color: ConfirmPopupColor.descriptionColor(context), + ), + ), + ], + ), + ], + ); + } +} + +Future deleteMyAccount( + BuildContext context, + String email, + bool isChecked, +) async { + final bottomPadding = PlatformExtension.isMobile + ? MediaQuery.of(context).viewInsets.bottom + : 0.0; + + if (!isChecked) { + showToastNotification( + context, + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_checkToConfirmError + .tr(), + ); + return; + } + + // fetch the user email from server instead of reading from provider, + // this is to avoid the email doesn't match the real user's email + final userEmail = await UserBackendService.getCurrentUserProfile() + .fold((s) => s.email, (_) => null); + + if (!context.mounted) { + return; + } + + if (userEmail == null) { + showToastNotification( + context, + type: ToastificationType.error, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_failedToGetCurrentUser + .tr(), + ); + return; + } + + if (email.isEmpty || email.toLowerCase() != userEmail.toLowerCase()) { + showToastNotification( + context, + type: ToastificationType.warning, + bottomPadding: bottomPadding, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_emailValidationFailed + .tr(), + ); + return; + } + + final loading = Loading(context)..start(); + + await UserBackendService.deleteCurrentAccount().fold( + (s) { + Log.info('account deletion success, email: $email'); + + loading.stop(); + showToastNotification( + context, + message: LocaleKeys + .newSettings_myAccount_deleteAccount_deleteAccountSuccess + .tr(), + bottomPadding: bottomPadding, + ); + + // delay 1 second to make sure the toast notification is shown + Future.delayed(const Duration(seconds: 1), () async { + // pop to the home screen + Navigator.of(context).popUntil((route) { + if (route.settings.name == '/') { + return true; + } + return false; + }); + + // restart the application + await getIt().signOut(); + await runAppFlowy(); + }); + }, + (f) { + Log.error('account deletion failed, email: $email, error: $f'); + + loading.stop(); + showToastNotification( + context, + type: ToastificationType.error, + bottomPadding: bottomPadding, + message: f.msg, + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart new file mode 100644 index 0000000000..d897a7931b --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_sign_in_out.dart @@ -0,0 +1,185 @@ +import 'package:appflowy/env/cloud_env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/auth/auth_service.dart'; +import 'package:appflowy/user/application/prelude.dart'; +import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AccountSignInOutButton extends StatelessWidget { + const AccountSignInOutButton({ + super.key, + required this.userProfile, + required this.onAction, + this.signIn = true, + }); + + final UserProfilePB userProfile; + final VoidCallback onAction; + final bool signIn; + + @override + Widget build(BuildContext context) { + return PrimaryRoundedButton( + text: signIn + ? LocaleKeys.settings_accountPage_login_loginLabel.tr() + : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + fontWeight: FontWeight.w600, + radius: 12.0, + onTap: () => + signIn ? _showSignInDialog(context) : _showLogoutDialog(context), + ); + } + + void _showLogoutDialog(BuildContext context) { + showConfirmDialog( + context: context, + title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), + description: userProfile.encryptionType == EncryptionTypePB.Symmetric + ? LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr() + : LocaleKeys.settings_menu_logoutPrompt.tr(), + onConfirm: () async { + await getIt().signOut(); + onAction(); + }, + ); + } + + Future _showSignInDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (context) => BlocProvider( + create: (context) => getIt(), + child: const FlowyDialog( + constraints: BoxConstraints(maxHeight: 485, maxWidth: 375), + child: _SignInDialogContent(), + ), + ), + ); + } +} + +class _SignInDialogContent extends StatelessWidget { + const _SignInDialogContent(); + + @override + Widget build(BuildContext context) { + return ScaffoldMessenger( + child: Scaffold( + body: Padding( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + children: [ + const _DialogHeader(), + const _DialogTitle(), + const VSpace(16), + const SignInWithMagicLinkButtons(), + if (isAuthEnabled) ...[ + const VSpace(20), + const _OrDivider(), + const VSpace(10), + SettingThirdPartyLogin( + didLogin: () {}, + ), // TODO: Pass onAction + ], + ], + ), + ), + ), + ), + ); + } +} + +class _DialogHeader extends StatelessWidget { + const _DialogHeader(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildBackButton(context), + _buildCloseButton(context), + ], + ); + } + + Widget _buildBackButton(BuildContext context) { + return GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + const FlowySvg(FlowySvgs.arrow_back_m, size: Size.square(24)), + const HSpace(8), + FlowyText.semibold(LocaleKeys.button_back.tr(), fontSize: 16), + ], + ), + ), + ); + } + + Widget _buildCloseButton(BuildContext context) { + return GestureDetector( + onTap: Navigator.of(context).pop, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: FlowySvg( + FlowySvgs.m_close_m, + size: const Size.square(20), + color: Theme.of(context).colorScheme.outline, + ), + ), + ); + } +} + +class _DialogTitle extends StatelessWidget { + const _DialogTitle(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: FlowyText.medium( + LocaleKeys.settings_accountPage_login_loginLabel.tr(), + fontSize: 22, + color: Theme.of(context).colorScheme.tertiary, + maxLines: null, + ), + ), + ], + ); + } +} + +class _OrDivider extends StatelessWidget { + const _OrDivider(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Flexible(child: Divider(thickness: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: FlowyText.regular(LocaleKeys.signIn_or.tr()), + ), + const Flexible(child: Divider(thickness: 1)), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart new file mode 100644 index 0000000000..90c4b6c2ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/account/account_user_profile.dart @@ -0,0 +1,179 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; +import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +// Account name and account avatar +class AccountUserProfile extends StatefulWidget { + const AccountUserProfile({ + super.key, + required this.name, + required this.iconUrl, + this.onSave, + }); + + final String name; + final String iconUrl; + final void Function(String)? onSave; + + @override + State createState() => _AccountUserProfileState(); +} + +class _AccountUserProfileState extends State { + late final TextEditingController nameController = + TextEditingController(text: widget.name); + final FocusNode focusNode = FocusNode(); + bool isEditing = false; + bool isHovering = false; + + @override + void initState() { + super.initState(); + + focusNode + ..addListener(_handleFocusChange) + ..onKeyEvent = _handleKeyEvent; + } + + @override + void dispose() { + nameController.dispose(); + focusNode.removeListener(_handleFocusChange); + focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAvatar(), + const HSpace(16), + Flexible( + child: isEditing ? _buildEditingField() : _buildNameDisplay(), + ), + ], + ); + } + + Widget _buildAvatar() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showIconPickerDialog(context), + child: FlowyHover( + resetHoverOnRebuild: false, + onHover: (state) => setState(() => isHovering = state), + style: HoverStyle( + hoverColor: Colors.transparent, + borderRadius: BorderRadius.circular(100), + ), + child: FlowyTooltip( + message: + LocaleKeys.settings_accountPage_general_changeProfilePicture.tr(), + child: UserAvatar( + iconUrl: widget.iconUrl, + name: widget.name, + size: 48, + fontSize: 20, + isHovering: isHovering, + ), + ), + ), + ); + } + + Widget _buildNameDisplay() { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FlowyText.medium( + widget.name, + overflow: TextOverflow.ellipsis, + ), + ), + const HSpace(4), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => isEditing = true), + child: const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4), + child: FlowySvg(FlowySvgs.edit_s), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEditingField() { + return SettingsInputField( + textController: nameController, + value: widget.name, + focusNode: focusNode..requestFocus(), + onCancel: () => setState(() => isEditing = false), + onSave: (_) => _saveChanges(), + ); + } + + Future _showIconPickerDialog(BuildContext context) { + return showDialog( + context: context, + builder: (dialogContext) => SimpleDialog( + children: [ + Container( + height: 380, + width: 360, + margin: const EdgeInsets.all(0), + child: FlowyIconEmojiPicker( + onSelectedEmoji: (r) { + context + .read() + .add(SettingsUserEvent.updateUserIcon(iconUrl: r.emoji)); + Navigator.of(dialogContext).pop(); + }, + ), + ), + ], + ), + ); + } + + void _handleFocusChange() { + if (!focusNode.hasFocus && isEditing && mounted) { + _saveChanges(); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape && + isEditing && + mounted) { + setState(() => isEditing = false); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _saveChanges() { + widget.onSave?.call(nameController.text); + setState(() => isEditing = false); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index d2ac61e472..bb0e4aac9f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -1,25 +1,15 @@ import 'package:appflowy/env/cloud_env.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/auth/auth_service.dart'; -import 'package:appflowy/user/application/prelude.dart'; -import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/magic_link_sign_in_buttons.dart'; import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; -import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart'; import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsAccountView extends StatefulWidget { @@ -56,10 +46,11 @@ class _SettingsAccountViewState extends State { return SettingsBody( title: LocaleKeys.settings_accountPage_title.tr(), children: [ + // user profile SettingsCategory( title: LocaleKeys.settings_accountPage_general_title.tr(), children: [ - UserProfileSetting( + AccountUserProfile( name: userName, iconUrl: state.userProfile.iconUrl, onSave: (newName) { @@ -75,6 +66,7 @@ class _SettingsAccountViewState extends State { ], ), + // user email // Only show email if the user is authenticated and not using local auth if (isAuthEnabled && state.userProfile.authenticator != AuthenticatorPB.Local) ...[ @@ -82,62 +74,15 @@ class _SettingsAccountViewState extends State { title: LocaleKeys.settings_accountPage_email_title.tr(), children: [ FlowyText.regular(state.userProfile.email), - // Enable when/if we need change email feature - // SingleSettingAction( - // label: state.userProfile.email, - // buttonLabel: LocaleKeys - // .settings_accountPage_email_actions_change - // .tr(), - // onPressed: () => SettingsAlertDialog( - // title: LocaleKeys - // .settings_accountPage_email_actions_change - // .tr(), - // confirmLabel: LocaleKeys.button_save.tr(), - // confirm: () { - // context.read().add( - // SettingsUserEvent.updateUserEmail( - // _emailController.text, - // ), - // ); - // Navigator.of(context).pop(); - // }, - // children: [ - // SettingsInputField( - // label: LocaleKeys.settings_accountPage_email_title - // .tr(), - // value: state.userProfile.email, - // hideActions: true, - // textController: _emailController, - // ), - // ], - // ).show(context), - // ), ], ), ], - /// Enable when we have change password feature and 2FA - // const SettingsCategorySpacer(), - // SettingsCategory( - // title: 'Account & security', - // children: [ - // SingleSettingAction( - // label: '**********', - // buttonLabel: 'Change password', - // onPressed: () {}, - // ), - // SingleSettingAction( - // label: '2-step authentication', - // buttonLabel: 'Enable 2FA', - // onPressed: () {}, - // ), - // ], - // ), - + // user sign in/out SettingsCategory( title: LocaleKeys.settings_accountPage_login_title.tr(), children: [ - SignInOutButton( + AccountSignInOutButton( userProfile: state.userProfile, onAction: state.userProfile.authenticator == AuthenticatorPB.Local @@ -149,22 +94,10 @@ class _SettingsAccountViewState extends State { ], ), - /// Enable when we can delete accounts - // const SettingsCategorySpacer(), - // SettingsSubcategory( - // title: 'Delete account', - // children: [ - // SingleSettingAction( - // label: - // 'Permanently delete your account and remove access from all teamspaces.', - // labelMaxLines: 4, - // onPressed: () {}, - // buttonLabel: 'Delete my account', - // isDangerous: true, - // fontSize: 12, - // ), - // ], - // ), + // user deletion + if (widget.userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + const AccountDeletionButton(), ], ); }, @@ -172,308 +105,3 @@ class _SettingsAccountViewState extends State { ); } } - -@visibleForTesting -class SignInOutButton extends StatelessWidget { - const SignInOutButton({ - super.key, - required this.userProfile, - required this.onAction, - this.signIn = true, - }); - - final UserProfilePB userProfile; - final VoidCallback onAction; - final bool signIn; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 48, - child: PrimaryRoundedButton( - text: signIn - ? LocaleKeys.settings_accountPage_login_loginLabel.tr() - : LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - fontWeight: FontWeight.w600, - radius: 12.0, - onTap: () { - if (signIn) { - _showSignInDialog(context); - } else { - showConfirmDialog( - context: context, - title: LocaleKeys.settings_accountPage_login_logoutLabel.tr(), - description: switch (userProfile.encryptionType) { - EncryptionTypePB.Symmetric => - LocaleKeys.settings_menu_selfEncryptionLogoutPrompt.tr(), - _ => LocaleKeys.settings_menu_logoutPrompt.tr(), - }, - onConfirm: () async { - await getIt().signOut(); - onAction(); - }, - ); - } - }, - ), - ), - ], - ); - } - - Future _showSignInDialog(BuildContext context) async { - await showDialog( - context: context, - builder: (context) => BlocProvider( - create: (context) => getIt(), - child: FlowyDialog( - constraints: const BoxConstraints( - maxHeight: 485, - maxWidth: 375, - ), - child: ScaffoldMessenger( - child: Scaffold( - body: Padding( - padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - GestureDetector( - onTap: Navigator.of(context).pop, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Row( - children: [ - const FlowySvg( - FlowySvgs.arrow_back_m, - size: Size.square(24), - ), - const HSpace(8), - FlowyText.semibold( - LocaleKeys.button_back.tr(), - fontSize: 16, - ), - ], - ), - ), - ), - const Spacer(), - GestureDetector( - onTap: Navigator.of(context).pop, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: FlowySvg( - FlowySvgs.m_close_m, - size: const Size.square(20), - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: FlowyText.medium( - LocaleKeys.settings_accountPage_login_loginLabel - .tr(), - fontSize: 22, - color: Theme.of(context).colorScheme.tertiary, - maxLines: null, - ), - ), - ], - ), - const VSpace(16), - const SignInWithMagicLinkButtons(), - if (isAuthEnabled) ...[ - const VSpace(20), - Row( - children: [ - const Flexible(child: Divider(thickness: 1)), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - ), - child: FlowyText.regular( - LocaleKeys.signIn_or.tr(), - ), - ), - const Flexible(child: Divider(thickness: 1)), - ], - ), - const VSpace(10), - SettingThirdPartyLogin(didLogin: onAction), - ], - ], - ), - ), - ), - ), - ), - ), - ), - ); - } -} - -@visibleForTesting -class UserProfileSetting extends StatefulWidget { - const UserProfileSetting({ - super.key, - required this.name, - required this.iconUrl, - this.onSave, - }); - - final String name; - final String iconUrl; - final void Function(String)? onSave; - - @override - State createState() => _UserProfileSettingState(); -} - -class _UserProfileSettingState extends State { - late final _nameController = TextEditingController(text: widget.name); - late final FocusNode focusNode; - bool isEditing = false; - bool isHovering = false; - - @override - void initState() { - super.initState(); - focusNode = FocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape && - isEditing && - mounted) { - setState(() => isEditing = false); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - )..addListener(() { - if (!focusNode.hasFocus && isEditing && mounted) { - widget.onSave?.call(_nameController.text); - setState(() => isEditing = false); - } - }); - } - - @override - void dispose() { - focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _showIconPickerDialog(context), - child: FlowyHover( - resetHoverOnRebuild: false, - onHover: (state) => setState(() => isHovering = state), - style: HoverStyle( - hoverColor: Colors.transparent, - borderRadius: BorderRadius.circular(100), - ), - child: FlowyTooltip( - message: LocaleKeys - .settings_accountPage_general_changeProfilePicture - .tr(), - child: UserAvatar( - iconUrl: widget.iconUrl, - name: widget.name, - size: 48, - fontSize: 20, - isHovering: isHovering, - ), - ), - ), - ), - const HSpace(16), - if (!isEditing) ...[ - Flexible( - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: FlowyText.medium( - widget.name, - overflow: TextOverflow.ellipsis, - ), - ), - const HSpace(4), - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => isEditing = true), - child: const FlowyHover( - resetHoverOnRebuild: false, - child: Padding( - padding: EdgeInsets.all(4), - child: FlowySvg(FlowySvgs.edit_s), - ), - ), - ), - ], - ), - ), - ), - ] else ...[ - Flexible( - child: SettingsInputField( - textController: _nameController, - value: widget.name, - focusNode: focusNode..requestFocus(), - onCancel: () => setState(() => isEditing = false), - onSave: (val) { - widget.onSave?.call(val); - setState(() => isEditing = false); - }, - ), - ), - ], - ], - ); - } - - Future _showIconPickerDialog(BuildContext context) { - return showDialog( - context: context, - builder: (dialogContext) => SimpleDialog( - children: [ - Container( - height: 380, - width: 360, - margin: const EdgeInsets.all(0), - child: FlowyIconEmojiPicker( - onSelectedEmoji: (r) { - context - .read() - .add(SettingsUserEvent.updateUserIcon(iconUrl: r.emoji)); - Navigator.of(dialogContext).pop(); - }, - ), - ), - ], - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart index 744c042c62..ee73768c9d 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/_restart_app_button.dart @@ -67,8 +67,8 @@ class RestartButton extends StatelessWidget { // ], // ); } else { - return MobileSignInOrLogoutButton( - labelText: LocaleKeys.settings_menu_restartApp.tr(), + return MobileLogoutButton( + text: LocaleKeys.settings_menu_restartApp.tr(), onPressed: onClick, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart index b8421235d3..421ffea508 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/dialogs.dart @@ -523,3 +523,35 @@ Future showCustomConfirmDialog({ }, ); } + +Future showCancelAndDeleteDialog({ + required BuildContext context, + required String title, + required String description, + required Widget Function(BuildContext) builder, + VoidCallback? onDelete, + String? confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + width: 440, + child: ConfirmPopup( + title: title, + description: description, + onConfirm: () => onDelete?.call(), + closeOnAction: false, + confirmLabel: confirmLabel, + confirmButtonColor: Theme.of(context).colorScheme.error, + child: builder(context), + ), + ), + ); + }, + ); +} diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index f0399ad2a1..8f8141641d 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.6.8 +version: 0.6.9 environment: flutter: ">=3.22.0" diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 9d3fadd6c6..9e1e271445 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2227,10 +2227,17 @@ "deleteAccount": { "title": "Delete Account", "subtitle": "Permanently delete your account and all of your data.", + "description": "Permanently delete your account and remove access from all teamspaces.", "deleteMyAccount": "Delete my account", "dialogTitle": "Delete account", "dialogContent1": "Are you sure you want to permanently delete your account?", - "dialogContent2": "This action cannot be undone, and will remove access from all teamspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces." + "dialogContent2": "This action cannot be undone, and will remove access from all teamspaces, erasing your entire account, including private workspaces, and removing you from all shared workspaces.", + "confirmHint1": "Please type in your email address to confirm.", + "confirmHint2": "I understand that this action is irreversible and will permanently delete my account and all associated data.", + "checkToConfirmError": "You must check the box to confirm deletion", + "failedToGetCurrentUser": "Failed to get current user email", + "emailValidationFailed": "Your email address does not match the account email address", + "deleteAccountSuccess": "Account deleted successfully" } }, "workplace": {