diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 53d473bb..34b6b093 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2380,5 +2380,10 @@ "databaseMigrationBody": "Please wait. This may take a moment.", "leaveEmptyToClearStatus": "Leave empty to clear your status.", "select": "Select", - "searchForUsers": "Search for @users..." + "searchForUsers": "Search for @users...", + "pleaseEnterYourCurrentPassword": "Please enter your current password", + "newPassword": "New password", + "pleaseChooseAStrongPassword": "Please choose a strong password", + "passwordsDoNotMatch": "Passwords do not match", + "passwordIsWrong": "Your entered password is wrong" } \ No newline at end of file diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 0f6e6dd1..3c5818ea 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -26,6 +26,7 @@ import 'package:fluffychat/pages/settings_emotes/settings_emotes.dart'; import 'package:fluffychat/pages/settings_ignore_list/settings_ignore_list.dart'; import 'package:fluffychat/pages/settings_multiple_emotes/settings_multiple_emotes.dart'; import 'package:fluffychat/pages/settings_notifications/settings_notifications.dart'; +import 'package:fluffychat/pages/settings_password/settings_password.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; @@ -241,6 +242,16 @@ abstract class AppRoutes { const SettingsSecurity(), ), routes: [ + GoRoute( + path: 'password', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + const SettingsPassword(), + ); + }, + redirect: loggedOutRedirect, + ), GoRoute( path: 'ignorelist', pageBuilder: (context, state) { diff --git a/lib/pages/settings_password/settings_password.dart b/lib/pages/settings_password/settings_password.dart new file mode 100644 index 00000000..cb631d7f --- /dev/null +++ b/lib/pages/settings_password/settings_password.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/pages/settings_password/settings_password_view.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SettingsPassword extends StatefulWidget { + const SettingsPassword({super.key}); + + @override + SettingsPasswordController createState() => SettingsPasswordController(); +} + +class SettingsPasswordController extends State { + final TextEditingController oldPasswordController = TextEditingController(); + final TextEditingController newPassword1Controller = TextEditingController(); + final TextEditingController newPassword2Controller = TextEditingController(); + + String? oldPasswordError; + String? newPassword1Error; + String? newPassword2Error; + + bool loading = false; + + void changePassword() async { + setState(() { + oldPasswordError = newPassword1Error = newPassword2Error = null; + }); + if (oldPasswordController.text.isEmpty) { + setState(() { + oldPasswordError = L10n.of(context)!.pleaseEnterYourPassword; + }); + return; + } + if (newPassword1Controller.text.isEmpty || + newPassword1Controller.text.length < 6) { + setState(() { + newPassword1Error = L10n.of(context)!.pleaseChooseAStrongPassword; + }); + return; + } + if (newPassword1Controller.text != newPassword2Controller.text) { + setState(() { + newPassword2Error = L10n.of(context)!.passwordsDoNotMatch; + }); + return; + } + + setState(() { + loading = true; + }); + try { + final scaffoldMessenger = ScaffoldMessenger.of(context); + await Matrix.of(context).client.changePassword( + newPassword1Controller.text, + oldPassword: oldPasswordController.text, + ); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(L10n.of(context)!.passwordHasBeenChanged), + ), + ); + if (mounted) context.pop(); + } catch (e) { + setState(() { + newPassword2Error = e.toLocalizedString( + context, + ExceptionContext.changePassword, + ); + }); + } finally { + setState(() { + loading = false; + }); + } + } + + @override + Widget build(BuildContext context) => SettingsPasswordView(this); +} diff --git a/lib/pages/settings_password/settings_password_view.dart b/lib/pages/settings_password/settings_password_view.dart new file mode 100644 index 00000000..65b89f44 --- /dev/null +++ b/lib/pages/settings_password/settings_password_view.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/pages/settings_password/settings_password.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class SettingsPasswordView extends StatelessWidget { + final SettingsPasswordController controller; + const SettingsPasswordView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(L10n.of(context)!.changePassword)), + body: ListTileTheme( + iconColor: Theme.of(context).colorScheme.onBackground, + child: MaxWidthBody( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField( + controller: controller.oldPasswordController, + obscureText: true, + autocorrect: false, + autofocus: true, + readOnly: controller.loading, + decoration: InputDecoration( + hintText: L10n.of(context)!.pleaseEnterYourCurrentPassword, + errorText: controller.oldPasswordError, + ), + ), + const Divider(height: 32), + TextField( + controller: controller.newPassword1Controller, + obscureText: true, + autocorrect: false, + readOnly: controller.loading, + decoration: InputDecoration( + hintText: L10n.of(context)!.newPassword, + errorText: controller.newPassword1Error, + ), + ), + const SizedBox(height: 16), + TextField( + controller: controller.newPassword2Controller, + obscureText: true, + autocorrect: false, + readOnly: controller.loading, + decoration: InputDecoration( + hintText: L10n.of(context)!.repeatPassword, + errorText: controller.newPassword2Error, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: + controller.loading ? null : controller.changePassword, + icon: const Icon(Icons.send_outlined), + label: controller.loading + ? const LinearProgressIndicator() + : Text(L10n.of(context)!.changePassword), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/settings_security/settings_security.dart b/lib/pages/settings_security/settings_security.dart index 60f746e8..4482f764 100644 --- a/lib/pages/settings_security/settings_security.dart +++ b/lib/pages/settings_security/settings_security.dart @@ -23,42 +23,6 @@ class SettingsSecurity extends StatefulWidget { } class SettingsSecurityController extends State { - void changePasswordAccountAction() async { - final input = await showTextInputDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context)!.changePassword, - okLabel: L10n.of(context)!.ok, - cancelLabel: L10n.of(context)!.cancel, - textFields: [ - DialogTextField( - hintText: L10n.of(context)!.chooseAStrongPassword, - obscureText: true, - minLines: 1, - maxLines: 1, - ), - DialogTextField( - hintText: L10n.of(context)!.repeatPassword, - obscureText: true, - minLines: 1, - maxLines: 1, - ), - ], - ); - if (input == null) return; - final success = await showFutureLoadingDialog( - context: context, - future: () => Matrix.of(context) - .client - .changePassword(input.last, oldPassword: input.first), - ); - if (success.error == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context)!.passwordHasBeenChanged)), - ); - } - } - void setAppLockAction() async { if (AppLock.of(context).isActive) { AppLock.of(context).showLockScreen(); diff --git a/lib/pages/settings_security/settings_security_view.dart b/lib/pages/settings_security/settings_security_view.dart index 7a82483a..65a0d0b0 100644 --- a/lib/pages/settings_security/settings_security_view.dart +++ b/lib/pages/settings_security/settings_security_view.dart @@ -4,6 +4,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/utils/beautify_string_extension.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -20,66 +21,119 @@ class SettingsSecurityView extends StatelessWidget { body: ListTileTheme( iconColor: Theme.of(context).colorScheme.onBackground, child: MaxWidthBody( - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.block_outlined), - trailing: const Icon(Icons.chevron_right_outlined), - title: Text(L10n.of(context)!.blockedUsers), - onTap: () => context.go('/rooms/settings/security/ignorelist'), - ), - ListTile( - leading: const Icon(Icons.password_outlined), - trailing: const Icon(Icons.chevron_right_outlined), - title: Text( - L10n.of(context)!.changePassword, - ), - onTap: controller.changePasswordAccountAction, - ), - ListTile( - leading: const Icon(Icons.mail_outlined), - trailing: const Icon(Icons.chevron_right_outlined), - title: Text(L10n.of(context)!.passwordRecovery), - onTap: () => context.go('/rooms/settings/security/3pid'), - ), - if (Matrix.of(context).client.encryption != null) ...{ - const Divider(thickness: 1), - if (PlatformInfos.isMobile) + child: FutureBuilder( + future: Matrix.of(context) + .client + .getCapabilities() + .timeout(const Duration(seconds: 10)), + builder: (context, snapshot) { + final capabilities = snapshot.data; + final error = snapshot.error; + if (error == null && capabilities == null) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } + return Column( + children: [ + if (error != null) + ListTile( + leading: const Icon( + Icons.warning_outlined, + color: Colors.orange, + ), + title: Text( + error.toLocalizedString(context), + style: const TextStyle(color: Colors.orange), + ), + ), + if (capabilities?.mChangePassword?.enabled == true || + error != null) ...[ + ListTile( + leading: const Icon(Icons.password_outlined), + trailing: error != null + ? null + : const Icon(Icons.chevron_right_outlined), + title: Text( + L10n.of(context)!.changePassword, + style: TextStyle( + decoration: + error == null ? null : TextDecoration.lineThrough, + ), + ), + onTap: error != null + ? null + : () => + context.go('/rooms/settings/security/password'), + ), + ListTile( + leading: const Icon(Icons.mail_outlined), + trailing: error != null + ? null + : const Icon(Icons.chevron_right_outlined), + title: Text( + L10n.of(context)!.passwordRecovery, + style: TextStyle( + decoration: + error == null ? null : TextDecoration.lineThrough, + ), + ), + onTap: error != null + ? null + : () => context.go('/rooms/settings/security/3pid'), + ), + const Divider(), + ], ListTile( - leading: const Icon(Icons.lock_outlined), + leading: const Icon(Icons.block_outlined), trailing: const Icon(Icons.chevron_right_outlined), - title: Text(L10n.of(context)!.appLock), - onTap: controller.setAppLockAction, + title: Text(L10n.of(context)!.blockedUsers), + onTap: () => + context.go('/rooms/settings/security/ignorelist'), ), - ListTile( - title: Text(L10n.of(context)!.yourPublicKey), - subtitle: Text( - Matrix.of(context).client.fingerprintKey.beautified, - style: const TextStyle(fontFamily: 'monospace'), + if (Matrix.of(context).client.encryption != null) ...{ + const Divider(thickness: 1), + if (PlatformInfos.isMobile) + ListTile( + leading: const Icon(Icons.lock_outlined), + trailing: const Icon(Icons.chevron_right_outlined), + title: Text(L10n.of(context)!.appLock), + onTap: controller.setAppLockAction, + ), + ListTile( + title: Text(L10n.of(context)!.yourPublicKey), + subtitle: Text( + Matrix.of(context).client.fingerprintKey.beautified, + style: const TextStyle(fontFamily: 'monospace'), + ), + leading: const Icon(Icons.vpn_key_outlined), + ), + }, + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.tap_and_play), + trailing: const Icon(Icons.chevron_right_outlined), + title: Text( + L10n.of(context)!.dehydrate, + style: const TextStyle(color: Colors.red), + ), + onTap: controller.dehydrateAction, ), - leading: const Icon(Icons.vpn_key_outlined), - ), - }, - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.tap_and_play), - trailing: const Icon(Icons.chevron_right_outlined), - title: Text( - L10n.of(context)!.dehydrate, - style: const TextStyle(color: Colors.red), - ), - onTap: controller.dehydrateAction, - ), - ListTile( - leading: const Icon(Icons.delete_outlined), - trailing: const Icon(Icons.chevron_right_outlined), - title: Text( - L10n.of(context)!.deleteAccount, - style: const TextStyle(color: Colors.red), - ), - onTap: controller.deleteAccountAction, - ), - ], + ListTile( + leading: const Icon(Icons.delete_outlined), + trailing: const Icon(Icons.chevron_right_outlined), + title: Text( + L10n.of(context)!.deleteAccount, + style: const TextStyle(color: Colors.red), + ), + onTap: controller.deleteAccountAction, + ), + ], + ); + }, ), ), ), diff --git a/lib/utils/localized_exception_extension.dart b/lib/utils/localized_exception_extension.dart index abcdcea1..5a6dcae5 100644 --- a/lib/utils/localized_exception_extension.dart +++ b/lib/utils/localized_exception_extension.dart @@ -9,10 +9,16 @@ import 'package:matrix/matrix.dart'; import 'uia_request_manager.dart'; extension LocalizedExceptionExtension on Object { - String toLocalizedString(BuildContext context) { + String toLocalizedString( + BuildContext context, [ + ExceptionContext? exceptionContext, + ]) { if (this is MatrixException) { switch ((this as MatrixException).error) { case MatrixError.M_FORBIDDEN: + if (exceptionContext == ExceptionContext.changePassword) { + return L10n.of(context)!.passwordIsWrong; + } return L10n.of(context)!.noPermission; case MatrixError.M_LIMIT_EXCEEDED: return L10n.of(context)!.tooManyRequestsWarning; @@ -70,3 +76,7 @@ extension LocalizedExceptionExtension on Object { return L10n.of(context)!.oopsSomethingWentWrong; } } + +enum ExceptionContext { + changePassword, +}