From 4572e800a70eef2cbd1822a65ad9e280a853feef Mon Sep 17 00:00:00 2001 From: TheOneWithTheBraid Date: Sat, 12 Mar 2022 12:48:34 +0100 Subject: [PATCH] chore: implement new onboarding flow - simplify homeserver selection - allow to change avatar - use registration as main action - friendly animations Closes: !712 Signed-off-by: TheOneWithTheBraid --- assets/l10n/intl_de.arb | 2 +- assets/l10n/intl_en.arb | 17 +- assets/l10n/intl_fr.arb | 2 +- lib/config/routes.dart | 45 ++- lib/pages/connect/connect.dart | 355 ++++++++++++++++ lib/pages/connect/connect_view.dart | 352 ++++++++++++++++ .../homeserver_picker/homeserver_picker.dart | 275 +++++-------- .../homeserver_picker_view.dart | 378 ++++++------------ .../homeserver_picker/homeserver_tile.dart | 43 +- lib/pages/login/login.dart | 17 +- lib/pages/login/login_view.dart | 18 - lib/pages/sign_up/signup.dart | 22 +- lib/pages/sign_up/signup_view.dart | 28 +- 13 files changed, 1049 insertions(+), 505 deletions(-) create mode 100644 lib/pages/connect/connect.dart create mode 100644 lib/pages/connect/connect_view.dart diff --git a/assets/l10n/intl_de.arb b/assets/l10n/intl_de.arb index 96023eb0..fb316d3e 100644 --- a/assets/l10n/intl_de.arb +++ b/assets/l10n/intl_de.arb @@ -35,7 +35,7 @@ "username": {} } }, - "addEmail": "E-Mail hinzufügen", + "addEmail": "E-Mail hinzufügen (optional)", "@addEmail": { "type": "text", "placeholders": {} diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 5951edc1..3993be71 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -48,7 +48,7 @@ "username": {} } }, - "addEmail": "Add email", + "addEmail": "Add email (optional)", "@addEmail": { "type": "text", "placeholders": {} @@ -2785,5 +2785,18 @@ "responseTime": "Response time", "openServerList": "Visit", "reportServerListProblem": "Report list issue", - "serverListJoinMatrix": "Server list by joinMatrix.org" + "serverListJoinMatrix": "Server list by joinMatrix.org", + "sampleEmail": "email@example.abc", + "emailHelper": "Helps your friends finding your username. Possible privacy issue.", + "usernameTaken": "Username already taken. Chose another one or login instead.", + "inventPassword": "Now create a strong password. Don't forget to remember or note it.", + "signUpAs": "Sign up as {username}", + "@signUpAs": { + "type": "text", + "placeholders": { + "username": {} + } + }, + "selectCommunity": "Select community", + "customHomeserver": "Find a server or join your own..." } diff --git a/assets/l10n/intl_fr.arb b/assets/l10n/intl_fr.arb index f0dcfd8b..1baa5c02 100644 --- a/assets/l10n/intl_fr.arb +++ b/assets/l10n/intl_fr.arb @@ -35,7 +35,7 @@ "username": {} } }, - "addEmail": "Ajouter un courriel", + "addEmail": "Ajouter un courriel (optional)", "@addEmail": { "type": "text", "placeholders": {} diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 279df9f1..91706fee 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -9,10 +9,10 @@ import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_encryption_settings/chat_encryption_settings.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart'; +import 'package:fluffychat/pages/connect/connect.dart'; import 'package:fluffychat/pages/device_settings/device_settings.dart'; import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; -import 'package:fluffychat/pages/login/login.dart'; import 'package:fluffychat/pages/new_group/new_group.dart'; import 'package:fluffychat/pages/new_private_chat/new_private_chat.dart'; import 'package:fluffychat/pages/new_space/new_space.dart'; @@ -28,7 +28,6 @@ import 'package:fluffychat/pages/settings_notifications/settings_notifications.d import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_stories/settings_stories.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; -import 'package:fluffychat/pages/sign_up/signup.dart'; import 'package:fluffychat/pages/story/story_page.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/loading_view.dart'; @@ -252,14 +251,21 @@ class AppRoutes { buildTransition: _fadeTransition, stackedRoutes: [ VWidget( - path: 'login', - widget: const Login(), - buildTransition: _fadeTransition, - ), - VWidget( - path: 'signup', - widget: const SignupPage(), + path: 'connect', + widget: const ConnectPage(), buildTransition: _fadeTransition, + /*stackedRoutes: [ + VWidget( + path: 'login', + widget: const Login(), + buildTransition: _fadeTransition, + ), + VWidget( + path: 'signup', + widget: const SignupPage(), + buildTransition: _fadeTransition, + ), + ]*/ ), VWidget( path: 'logs', @@ -337,14 +343,21 @@ class AppRoutes { buildTransition: _fadeTransition, stackedRoutes: [ VWidget( - path: 'login', - widget: const Login(), - buildTransition: _fadeTransition, - ), - VWidget( - path: 'signup', - widget: const SignupPage(), + path: 'connect', + widget: const ConnectPage(), buildTransition: _fadeTransition, + /*stackedRoutes: [ + VWidget( + path: 'login', + widget: const Login(), + buildTransition: _fadeTransition, + ), + VWidget( + path: 'signup', + widget: const SignupPage(), + buildTransition: _fadeTransition, + ), + ]*/ ), ], ), diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart new file mode 100644 index 00000000..18d6d703 --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -0,0 +1,355 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:file_picker_cross/file_picker_cross.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:matrix/matrix.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:vrouter/vrouter.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/connect/connect_view.dart'; +import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; +import 'package:fluffychat/pages/settings/settings.dart'; +import 'package:fluffychat/utils/famedlysdk_store.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../../utils/localized_exception_extension.dart'; + +class ConnectPage extends StatefulWidget { + const ConnectPage({Key? key}) : super(key: key); + + @override + ConnectPageController createState() => ConnectPageController(); +} + +class ConnectPageController extends State { + final TextEditingController usernameController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + final TextEditingController passwordController2 = TextEditingController(); + final TextEditingController emailController = TextEditingController(); + String? error; + bool loading = true; + bool showPassword = false; + + MatrixFile? avatar; + + Timer? _coolDown; + String? username; + bool? usernameTaken; + + final usernameFormatter = + FilteringTextInputFormatter(RegExp(r'[a-z0-9\._]'), allow: true); + + void setUsername(String username) { + this.username = username; + setState(() { + usernameTaken = null; + }); + _coolDown?.cancel(); + if (username.isNotEmpty) { + _coolDown = Timer(const Duration(milliseconds: 500), checkUsername); + } + } + + void toggleShowPassword() => setState(() => showPassword = !showPassword); + + String? get domain => VRouter.of(context).queryParameters['domain']; + + final GlobalKey formKey = GlobalKey(); + + @override + void initState() { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { + checkHomeserverAction(); + }); + super.initState(); + } + + /// Starts an analysis of the given homeserver. It uses the current domain and + /// makes sure that it is prefixed with https. Then it searches for the + /// well-known information and forwards to the login page depending on the + /// login type. + Future checkHomeserverAction() async { + if (domain == null || domain!.isEmpty) { + throw L10n.of(context)!.changeTheHomeserver; + } + var homeserver = domain; + + if (!homeserver!.startsWith('https://')) { + homeserver = 'https://$homeserver'; + } + + setState(() { + error = _rawLoginTypes = registrationSupported = null; + loading = true; + }); + + try { + await Matrix.of(context).getLoginClient().checkHomeserver(homeserver); + + _rawLoginTypes = await Matrix.of(context).getLoginClient().request( + RequestType.GET, + '/client/r0/login', + ); + try { + await Matrix.of(context).getLoginClient().register(); + registrationSupported = true; + } on MatrixException catch (e) { + registrationSupported = e.requireAdditionalAuthentication; + } + } catch (e) { + setState(() => error = (e).toLocalizedString(context)); + } finally { + if (mounted) { + setState(() => loading = false); + } + } + } + + Map? _rawLoginTypes; + bool? registrationSupported; + + List get identityProviders { + if (!ssoLoginSupported) return []; + final rawProviders = _rawLoginTypes!.tryGetList('flows')!.singleWhere( + (flow) => + flow['type'] == AuthenticationTypes.sso)['identity_providers']; + final list = (rawProviders as List) + .map((json) => IdentityProvider.fromJson(json)) + .toList(); + if (PlatformInfos.isCupertinoStyle) { + list.sort((a, b) => a.brand == 'apple' ? -1 : 1); + } + return list; + } + + bool get passwordLoginSupported => + Matrix.of(context) + .getLoginClient() + .supportedLoginTypes + .contains(AuthenticationTypes.password) && + _rawLoginTypes! + .tryGetList('flows')! + .any((flow) => flow['type'] == AuthenticationTypes.password); + + bool get ssoLoginSupported => + Matrix.of(context) + .getLoginClient() + .supportedLoginTypes + .contains(AuthenticationTypes.sso) && + _rawLoginTypes! + .tryGetList('flows')! + .any((flow) => flow['type'] == AuthenticationTypes.sso); + + static const String ssoHomeserverKey = 'sso-homeserver'; + + void ssoLoginAction(String id) async { + if (kIsWeb) { + // We store the homserver in the local storage instead of a redirect + // parameter because of possible CSRF attacks. + Store().setItem(ssoHomeserverKey, + Matrix.of(context).getLoginClient().homeserver.toString()); + } + final redirectUrl = kIsWeb + ? html.window.origin! + '/web/auth.html' + : AppConfig.appOpenUrlScheme.toLowerCase() + '://login'; + final url = + '${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'; + final urlScheme = Uri.parse(redirectUrl).scheme; + final result = await FlutterWebAuth.authenticate( + url: url, + callbackUrlScheme: urlScheme, + ); + final token = Uri.parse(result).queryParameters['loginToken']; + if (token != null) _loginWithToken(token); + } + + void signUpAction() => VRouter.of(context).to( + 'signup', + queryParameters: {'domain': domain!, 'username': username!}, + ); + + void _loginWithToken(String token) { + if (token.isEmpty) return; + + showFutureLoadingDialog( + context: context, + future: () async { + if (Matrix.of(context).getLoginClient().homeserver == null) { + await Matrix.of(context).getLoginClient().checkHomeserver( + await Store().getItem(ConnectPageController.ssoHomeserverKey), + ); + } + await Matrix.of(context).getLoginClient().login( + LoginType.mLoginToken, + token: token, + initialDeviceDisplayName: PlatformInfos.clientName, + ); + }, + ); + } + + String? usernameTextFieldValidator(String? value) { + usernameController.text = + usernameController.text.trim().toLowerCase().replaceAll(' ', '_'); + if (value!.isEmpty) { + return L10n.of(context)!.pleaseChooseAUsername; + } + return null; + } + + String? password1TextFieldValidator(String? value) { + const minLength = 8; + if (value!.isEmpty) { + return L10n.of(context)!.chooseAStrongPassword; + } + if (value.length < minLength) { + return L10n.of(context)!.pleaseChooseAtLeastChars(minLength.toString()); + } + return null; + } + + String? password2TextFieldValidator(String? value) { + if (value!.isEmpty) { + return L10n.of(context)!.chooseAStrongPassword; + } + if (value != passwordController.text) { + return L10n.of(context)!.passwordsDoNotMatch; + } + return null; + } + + String? emailTextFieldValidator(String? value) { + if (value!.isNotEmpty && !value.contains('@')) { + return L10n.of(context)!.pleaseEnterValidEmail; + } + return null; + } + + void signup([_]) async { + setState(() { + error = null; + }); + if (!formKey.currentState!.validate()) return; + + setState(() { + loading = true; + }); + + try { + final client = Matrix.of(context).getLoginClient(); + final email = emailController.text; + if (email.isNotEmpty) { + Matrix.of(context).currentClientSecret = + DateTime.now().millisecondsSinceEpoch.toString(); + Matrix.of(context).currentThreepidCreds = + await client.requestTokenToRegisterEmail( + Matrix.of(context).currentClientSecret, + email, + 0, + ); + } + await client.uiaRequestBackground( + (auth) => client.register( + username: usernameController.text, + password: passwordController.text, + initialDeviceDisplayName: PlatformInfos.clientName, + auth: auth, + ), + ); + } catch (e) { + error = (e).toLocalizedString(context); + } finally { + if (mounted) { + setState(() => loading = false); + } + } + } + + @override + Widget build(BuildContext context) => ConnectPageView(this); + + Future setAvatarAction() async { + final actions = [ + if (PlatformInfos.isMobile) + SheetAction( + key: AvatarAction.camera, + label: L10n.of(context)!.openCamera, + isDefaultAction: true, + icon: Icons.camera_alt_outlined, + ), + SheetAction( + key: AvatarAction.file, + label: L10n.of(context)!.openGallery, + icon: Icons.photo_outlined, + ), + if (avatar != null) + SheetAction( + key: AvatarAction.remove, + label: L10n.of(context)!.removeYourAvatar, + isDestructiveAction: true, + icon: Icons.delete_outlined, + ), + ]; + final action = actions.length == 1 + ? actions.single + : await showModalActionSheet( + context: context, + title: L10n.of(context)!.changeYourAvatar, + actions: actions, + ); + if (action == null) return; + if (action == AvatarAction.remove) { + setState(() => avatar = null); + return; + } + if (PlatformInfos.isMobile) { + final result = await ImagePicker().pickImage( + source: action == AvatarAction.camera + ? ImageSource.camera + : ImageSource.gallery, + imageQuality: 50, + ); + if (result == null) return; + final file = await _buildAvatar(await result.readAsBytes(), result.name); + setState(() => avatar = file); + } else { + final result = + await FilePickerCross.importFromStorage(type: FileTypeCross.image); + if (result.fileName == null) return; + final file = await _buildAvatar(result.toUint8List(), result.path!); + + setState(() => avatar = file); + } + } + + // we don't need 4k wallpapers as avatars... + Future _buildAvatar(Uint8List bytes, String name) { + final file = MatrixImageFile( + bytes: bytes, + name: name, + ); + return file.generateThumbnail( + dimension: 1024, + compute: Matrix.of(context).getLoginClient().runInBackground, + ); + } + + void checkUsername() { + // TODO(TheOneWithTheBraid): Check whether username available + setState(() { + usernameTaken = false; + }); + } + + void applyAvatar() => Matrix.of(context).getLoginClient().setAvatar(avatar); +} diff --git a/lib/pages/connect/connect_view.dart b/lib/pages/connect/connect_view.dart new file mode 100644 index 00000000..154dee66 --- /dev/null +++ b/lib/pages/connect/connect_view.dart @@ -0,0 +1,352 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'package:animations/animations.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; +import 'package:fluffychat/pages/login/login.dart'; +import 'package:fluffychat/pages/sign_up/signup.dart'; +import 'package:fluffychat/widgets/layouts/one_page_card.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'connect.dart'; + +class ConnectPageView extends StatelessWidget { + final ConnectPageController controller; + + const ConnectPageView(this.controller, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return OnePageCard( + child: Scaffold( + appBar: AppBar( + title: Text(L10n.of(context)!.connect), + ), + body: controller.loading + ? const Center(child: CircularProgressIndicator()) + : Form( + key: controller.formKey, + child: ListView( + children: [ + _AvatarSelector(controller: controller), + Padding( + padding: const EdgeInsets.all(12.0), + child: TextFormField( + readOnly: controller.loading, + autocorrect: false, + controller: controller.usernameController, + autofocus: true, + inputFormatters: [controller.usernameFormatter], + autofillHints: controller.loading + ? null + : [AutofillHints.username], + validator: controller.usernameTextFieldValidator, + onChanged: controller.setUsername, + decoration: InputDecoration( + prefixIcon: + const Icon(Icons.account_circle_outlined), + hintText: L10n.of(context)!.username.toLowerCase(), + labelText: L10n.of(context)!.username, + prefixText: '@', + prefixStyle: + const TextStyle(fontWeight: FontWeight.bold), + suffixStyle: + const TextStyle(fontWeight: FontWeight.w200), + suffixText: ':${controller.domain}'), + ), + ), + SizedBox( + height: 56, + child: (controller.usernameTaken == true) + ? Center(child: Text(L10n.of(context)!.usernameTaken)) + : (controller.usernameTaken == false || + (controller.username == null && + controller.usernameTaken == null)) + ? Hero( + tag: 'loginButton', + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12), + child: OpenContainer( + closedColor: Colors.transparent, + closedElevation: 0, + openBuilder: (context, action) => + SignupPage( + username: controller.username, + onRegistrationComplete: + controller.applyAvatar, + ), + closedBuilder: (context, action) => + ElevatedButton( + onPressed: controller.loading || + controller.usernameTaken == null + ? null + : action, + child: controller.loading + ? const LinearProgressIndicator() + : Text(L10n.of(context)!.signUp), + ), + ), + ), + ) + : const Center( + child: CircularProgressIndicator(), + ), + ), + const Divider(), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 4.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (controller.ssoLoginSupported) + Row(children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.all(12.0), + child: + Text(L10n.of(context)!.loginWithOneClick), + ), + const Expanded(child: Divider()), + ]), + Wrap( + children: [ + if (controller.ssoLoginSupported) ...{ + for (final identityProvider + in controller.identityProviders) + _SsoButton( + onPressed: () => controller + .ssoLoginAction(identityProvider.id!), + identityProvider: identityProvider, + ), + }, + ].toList(), + ), + if (controller.ssoLoginSupported && + (controller.registrationSupported! || + controller.passwordLoginSupported)) + Row(children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.all(12.0), + child: Text(L10n.of(context)!.or), + ), + const Expanded(child: Divider()), + ]), + if (controller.passwordLoginSupported) ...[ + Center( + child: OpenContainer( + closedColor: Colors.transparent, + closedElevation: 0, + closedBuilder: (context, callback) => + _LoginButton( + onPressed: callback, + icon: Icon( + CupertinoIcons.lock_open_fill, + color: Theme.of(context) + .textTheme + .bodyText1! + .color, + ), + labelText: L10n.of(context)!.login, + ), + openBuilder: (context, action) => Login( + username: controller.usernameController.text, + ), + ), + ), + const SizedBox(height: 12), + ], + /*if (controller.registrationSupported!) + Center( + child: _LoginButton( + onPressed: controller.signUpAction, + icon: Icon( + CupertinoIcons.person_add, + color: Theme.of(context).textTheme.bodyText1!.color, + ), + labelText: L10n.of(context)!.register, + ), + ),*/ + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AvatarSelector extends StatelessWidget { + static const borderWidth = 4.0; + static const dimension = 128.0 + 64.0; + final ConnectPageController controller; + + const _AvatarSelector({required this.controller, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + height: dimension, + width: dimension, + alignment: Alignment.center, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Material( + borderRadius: BorderRadius.circular(dimension), + elevation: 4, + color: Theme.of(context).secondaryHeaderColor, + child: Padding( + padding: const EdgeInsets.all(borderWidth), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(dimension), + border: Border.all( + color: Theme.of(context).colorScheme.secondary, + width: borderWidth), + ), + child: controller.avatar != null + ? ClipRRect( + borderRadius: BorderRadius.circular(dimension), + child: Image.memory( + controller.avatar!.bytes, + height: dimension, + fit: BoxFit.cover, + ), + ) + : const Icon( + Icons.person_outline_outlined, + size: dimension * .66, + ), + ), + ), + ), + ), + Container( + margin: const EdgeInsets.all(8), + alignment: Alignment.bottomRight, + child: FloatingActionButton( + // mini: true, + onPressed: controller.setAvatarAction, + backgroundColor: Theme.of(context).backgroundColor, + foregroundColor: Theme.of(context).textTheme.bodyText1?.color, + child: const Icon(Icons.add), + tooltip: L10n.of(context)!.changeYourAvatar, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SsoButton extends StatelessWidget { + final IdentityProvider identityProvider; + final void Function()? onPressed; + + const _SsoButton({ + Key? key, + required this.identityProvider, + this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(7), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(7), + clipBehavior: Clip.hardEdge, + child: Padding( + padding: const EdgeInsets.all(2.0), + child: identityProvider.icon == null + ? const Icon(Icons.web_outlined) + : CachedNetworkImage( + imageUrl: Uri.parse(identityProvider.icon!) + .getDownloadLink( + Matrix.of(context).getLoginClient()) + .toString(), + width: 32, + height: 32, + ), + ), + ), + const SizedBox(height: 8), + Text( + identityProvider.name ?? + identityProvider.brand ?? + L10n.of(context)!.singlesignon, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).textTheme.subtitle2!.color, + ), + ), + ], + ), + ), + ); + } +} + +class _LoginButton extends StatelessWidget { + final String? labelText; + final Widget? icon; + final void Function()? onPressed; + + const _LoginButton({ + Key? key, + this.labelText, + this.icon, + this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + style: OutlinedButton.styleFrom( + minimumSize: const Size(256, 56), + textStyle: const TextStyle(fontWeight: FontWeight.bold), + backgroundColor: Theme.of(context).backgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + ), + ), + onPressed: onPressed, + icon: icon!, + label: Text( + labelText!, + style: TextStyle( + color: Theme.of(context).textTheme.bodyText1!.color, + ), + ), + ); + } +} diff --git a/lib/pages/homeserver_picker/homeserver_picker.dart b/lib/pages/homeserver_picker/homeserver_picker.dart index fabf6818..ee2f58be 100644 --- a/lib/pages/homeserver_picker/homeserver_picker.dart +++ b/lib/pages/homeserver_picker/homeserver_picker.dart @@ -1,24 +1,15 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:animations/animations.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_web_auth/flutter_web_auth.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart'; -import 'package:universal_html/html.dart' as html; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view.dart'; -import 'package:fluffychat/utils/famedlysdk_store.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/localized_exception_extension.dart'; -import 'homeserver_tile.dart'; class HomeserverPicker extends StatefulWidget { const HomeserverPicker({Key? key}) : super(key: key); @@ -41,40 +32,31 @@ class HomeserverPickerController extends State { late HomeserverListProvider parser; - void setDomain(String domain) { - this.domain = domain; - _coolDown?.cancel(); - if (domain.isNotEmpty) { - _coolDown = - Timer(const Duration(milliseconds: 500), checkHomeserverAction); - } - } + bool customHomeserverLoading = false; - void _loginWithToken(String token) { - if (token.isEmpty) return; + bool get foundCustomHomeserver => benchmarkResults! + .where((element) => element.homeserver.baseUrl.host == searchTerm) + .isEmpty; - showFutureLoadingDialog( - context: context, - future: () async { - if (Matrix.of(context).getLoginClient().homeserver == null) { - await Matrix.of(context).getLoginClient().checkHomeserver( - await Store() - .getItem(HomeserverPickerController.ssoHomeserverKey), - ); - } - await Matrix.of(context).getLoginClient().login( - LoginType.mLoginToken, - token: token, - initialDeviceDisplayName: PlatformInfos.clientName, - ); - }, - ); - } + String? searchTerm; + + List get filteredHomeservers => + searchTerm == null || searchTerm!.isEmpty + ? benchmarkResults! + : benchmarkResults! + .where((element) => + element.homeserver.baseUrl.host.contains(searchTerm!) || + (element.homeserver.description?.contains(searchTerm!) ?? + false)) + .toList(); + + get homeserverCount => + filteredHomeservers.length + + ((customHomeserverLoading || foundCustomHomeserver) ? 1 : 0); @override void initState() { super.initState(); - checkHomeserverAction(); benchmarkHomeServers(); } @@ -84,112 +66,8 @@ class HomeserverPickerController extends State { _intentDataStreamSubscription?.cancel(); } - String? _lastCheckedHomeserver; - - /// Starts an analysis of the given homeserver. It uses the current domain and - /// makes sure that it is prefixed with https. Then it searches for the - /// well-known information and forwards to the login page depending on the - /// login type. - Future checkHomeserverAction() async { - _coolDown?.cancel(); - if (_lastCheckedHomeserver == domain) return; - if (domain == null || domain!.isEmpty) { - throw L10n.of(context)!.changeTheHomeserver; - } - var homeserver = domain; - - if (!homeserver!.startsWith('https://')) { - homeserver = 'https://$homeserver'; - } - - setState(() { - error = _rawLoginTypes = registrationSupported = null; - isLoading = true; - }); - - try { - await Matrix.of(context).getLoginClient().checkHomeserver(homeserver); - - _rawLoginTypes = await Matrix.of(context).getLoginClient().request( - RequestType.GET, - '/client/r0/login', - ); - try { - await Matrix.of(context).getLoginClient().register(); - registrationSupported = true; - } on MatrixException catch (e) { - registrationSupported = e.requireAdditionalAuthentication; - } - } catch (e) { - setState(() => error = (e).toLocalizedString(context)); - } finally { - _lastCheckedHomeserver = domain; - if (mounted) { - setState(() => isLoading = false); - } - } - } - - Map? _rawLoginTypes; - bool? registrationSupported; - - List get identityProviders { - if (!ssoLoginSupported) return []; - final rawProviders = _rawLoginTypes!.tryGetList('flows')!.singleWhere( - (flow) => - flow['type'] == AuthenticationTypes.sso)['identity_providers']; - final list = (rawProviders as List) - .map((json) => IdentityProvider.fromJson(json)) - .toList(); - if (PlatformInfos.isCupertinoStyle) { - list.sort((a, b) => a.brand == 'apple' ? -1 : 1); - } - return list; - } - - bool get passwordLoginSupported => - Matrix.of(context) - .client - .supportedLoginTypes - .contains(AuthenticationTypes.password) && - _rawLoginTypes! - .tryGetList('flows')! - .any((flow) => flow['type'] == AuthenticationTypes.password); - - bool get ssoLoginSupported => - Matrix.of(context) - .client - .supportedLoginTypes - .contains(AuthenticationTypes.sso) && - _rawLoginTypes! - .tryGetList('flows')! - .any((flow) => flow['type'] == AuthenticationTypes.sso); - - static const String ssoHomeserverKey = 'sso-homeserver'; - - void ssoLoginAction(String id) async { - if (kIsWeb) { - // We store the homserver in the local storage instead of a redirect - // parameter because of possible CSRF attacks. - Store().setItem(ssoHomeserverKey, - Matrix.of(context).getLoginClient().homeserver.toString()); - } - final redirectUrl = kIsWeb - ? html.window.origin! + '/web/auth.html' - : AppConfig.appOpenUrlScheme.toLowerCase() + '://login'; - final url = - '${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'; - final urlScheme = Uri.parse(redirectUrl).scheme; - final result = await FlutterWebAuth.authenticate( - url: url, - callbackUrlScheme: urlScheme, - ); - final token = Uri.parse(result).queryParameters['loginToken']; - if (token != null) _loginWithToken(token); - } - void signUpAction() => VRouter.of(context).to( - 'signup', + 'connect', queryParameters: {'domain': domain!}, ); @@ -228,45 +106,102 @@ class HomeserverPickerController extends State { badServers.sort(); benchmarkResults = List.from([...goodServers, ...badServers]); - + bool foundRegistrationSupported = false; domain = benchmarkResults!.first.homeserver.baseUrl.host; + int counter = 0; + // iterating up to first homeserver supporting registration + while (foundRegistrationSupported == false) { + try { + var homeserver = domain; + + if (!homeserver!.startsWith('https://')) { + homeserver = 'https://$homeserver'; + } + final loginClient = Matrix.of(context).getLoginClient(); + await loginClient.checkHomeserver(homeserver); + await loginClient.register(); + foundRegistrationSupported = true; + } on MatrixException catch (e) { + if (e.requireAdditionalAuthentication) { + foundRegistrationSupported = true; + } else { + Logs().d(e.toString()); + foundRegistrationSupported = false; + counter++; + domain = benchmarkResults![counter].homeserver.baseUrl.host; + } + } + setState(() { + isLoading = false; + }); + } } on Exception catch (e, s) { Logs().e('Homeserver benchmark failed', e, s); domain = AppConfig.defaultHomeserver; } finally { - homeserverController = TextEditingController(text: domain); - checkHomeserverAction(); + homeserverController = TextEditingController(); } } - Future showServerPicker() async { - final selection = await showModal( - context: context, - builder: (context) => SimpleDialog( - title: Text(L10n.of(context)!.changeTheHomeserver), - children: [ - ...benchmarkResults!.map( - (e) => HomeserverTile( - benchmark: e, - onSelect: () { - Navigator.of(context).pop(e.homeserver); - }, - ), - ), - const Divider(), - JoinMatrixAttributionTile(), - ]), - ); - if (selection is Homeserver) { - if (domain != selection.baseUrl.host) { - setState(() { - domain = selection.baseUrl.host; - homeserverController!.text = domain!; - }); - checkHomeserverAction(); + Future setHomeserver(String homeserver) async { + domain = homeserver; + signUpAction(); + } + + Future checkHomeserverAction(String homeserver) async { + setState(() { + error = null; + customHomeserverLoading = true; + }); + + try { + await Matrix.of(context).getLoginClient().checkHomeserver(homeserver); + setState(() { + domain = homeserver; + }); + } catch (e) { + setState(() => error = (e).toLocalizedString(context)); + } finally { + if (mounted) { + customHomeserverLoading = false; + setState(() => isLoading = false); } } } + + void searchHomeserver(String searchTerm) { + setState(() { + searchTerm = searchTerm; + + error = null; + }); + + if (searchTerm.length >= 3) { + var homeserver = searchTerm; + if (!homeserver.startsWith('https://')) { + homeserver = 'https://$homeserver'; + } + if (Uri.tryParse(homeserver) != null) { + _coolDown?.cancel(); + _coolDown = Timer( + const Duration(milliseconds: 500), + () => checkHomeserverAction(homeserver), + ); + } else { + setState(() { + customHomeserverLoading = false; + }); + } + } else { + setState(() { + customHomeserverLoading = false; + }); + } + } + + void setDomain(String? domain) => setState(() => this.domain = domain); + + void unsetDomain() => setState(() => domain = null); } class IdentityProvider { diff --git a/lib/pages/homeserver_picker/homeserver_picker_view.dart b/lib/pages/homeserver_picker/homeserver_picker_view.dart index b8d49e3c..7d451d14 100644 --- a/lib/pages/homeserver_picker/homeserver_picker_view.dart +++ b/lib/pages/homeserver_picker/homeserver_picker_view.dart @@ -1,19 +1,14 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:animations/animations.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/widgets/default_app_bar_search_field.dart'; import 'package:fluffychat/widgets/layouts/one_page_card.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'homeserver_picker.dart'; +import 'homeserver_tile.dart'; class HomeserverPickerView extends StatelessWidget { final HomeserverPickerController controller; @@ -24,167 +19,150 @@ class HomeserverPickerView extends StatelessWidget { Widget build(BuildContext context) { return OnePageCard( child: Scaffold( - appBar: AppBar( - titleSpacing: 8, - title: PageTransitionSwitcher( - transitionBuilder: ( - Widget child, - Animation primaryAnimation, - Animation secondaryAnimation, - ) { - return SharedAxisTransition( - animation: primaryAnimation, - secondaryAnimation: secondaryAnimation, - transitionType: SharedAxisTransitionType.vertical, - fillColor: Colors.transparent, - child: child, - ); - }, - child: controller.homeserverController == null - ? Center( - key: ValueKey(controller.homeserverController), - child: const CircularProgressIndicator(), - ) - : DefaultAppBarSearchField( - key: ValueKey(controller.homeserverController), - prefixIcon: IconButton( - icon: const Icon(Icons.format_list_numbered), - onPressed: controller.showServerPicker, - tooltip: L10n.of(context)!.showAvailableHomeservers, - ), - prefixText: 'https://', - hintText: L10n.of(context)!.enterYourHomeserver, - searchController: controller.homeserverController, - suffix: const Icon(Icons.edit_outlined), - padding: EdgeInsets.zero, - onChanged: controller.setDomain, - readOnly: !AppConfig.allowOtherHomeservers, - onSubmit: (_) => controller.checkHomeserverAction(), - unfocusOnClear: false, - autocorrect: false, - labelText: L10n.of(context)!.homeserver, - ), - ), - elevation: 0, - ), - body: ListView(children: [ - Image.asset( - Theme.of(context).brightness == Brightness.dark - ? 'assets/banner_dark.png' - : 'assets/banner.png', - ), - if (controller.isLoading) - const Center( - child: CircularProgressIndicator.adaptive(strokeWidth: 2)) - else if (controller.error != null) - Center( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - controller.error!, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - color: Colors.red[900], - ), - ), - ), - ) - else - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 4.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (controller.ssoLoginSupported) - Row(children: [ - const Expanded(child: Divider()), - Padding( - padding: const EdgeInsets.all(12.0), - child: Text(L10n.of(context)!.loginWithOneClick), + appBar: AppBar(title: Text(L10n.of(context)!.selectCommunity)), + body: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.vertical, + fillColor: Colors.transparent, + child: child, + ); + }, + child: controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : CustomScrollView( + key: ValueKey(controller.isLoading), + slivers: [ + SliverAppBar( + // pinned: _pinned, + // snap: _snap, + // floating: _floating, + expandedHeight: 256, + automaticallyImplyLeading: false, + flexibleSpace: FlexibleSpaceBar( + background: Image.asset( + Theme.of(context).brightness == Brightness.dark + ? 'assets/banner_dark.png' + : 'assets/banner.png', + ), ), - const Expanded(child: Divider()), - ]), - Wrap( - children: [ - if (controller.ssoLoginSupported) ...{ - for (final identityProvider - in controller.identityProviders) - _SsoButton( - onPressed: () => - controller.ssoLoginAction(identityProvider.id!), - identityProvider: identityProvider, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: controller.homeserverController, + readOnly: !AppConfig.allowOtherHomeservers, + autocorrect: false, + onChanged: controller.searchHomeserver, + decoration: InputDecoration( + // prefixText: 'https://', + hintText: L10n.of(context)!.homeserver, + suffixIcon: const Icon(Icons.search), + labelText: L10n.of(context)!.customHomeserver, ), - }, - ].toList(), - ), - if (controller.ssoLoginSupported && - (controller.registrationSupported! || - controller.passwordLoginSupported)) - Row(children: [ - const Expanded(child: Divider()), - Padding( - padding: const EdgeInsets.all(12.0), - child: Text(L10n.of(context)!.or), - ), - const Expanded(child: Divider()), - ]), - if (controller.passwordLoginSupported) ...[ - Center( - child: _LoginButton( - onPressed: () => VRouter.of(context).to('login'), - icon: Icon( - CupertinoIcons.lock_open_fill, - color: Theme.of(context).textTheme.bodyText1!.color, ), - labelText: L10n.of(context)!.login, ), ), - const SizedBox(height: 12), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Material( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).bottomAppBarTheme.color, + clipBehavior: Clip.hardEdge, + child: ListView.builder( + shrinkWrap: true, + itemCount: controller.homeserverCount, + itemBuilder: (BuildContext context, int index) { + if (controller.filteredHomeservers.length <= + index) { + if (controller.customHomeserverLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + ), + ); + } else { + if (controller.domain != null) { + return CustomHomeserverTile( + domain: controller.domain!, + onSelect: () => controller + .setHomeserver(controller.domain!), + ); + } else { + return Container(); + } + } + } + final e = controller.filteredHomeservers[index]; + return HomeserverTile( + benchmark: e, + controller: controller, + ); + }, + ), + ), + ), + ), + SliverToBoxAdapter( + child: Column( + children: [ + const Divider(), + JoinMatrixAttributionTile(), + ], + ), + ) ], - if (controller.registrationSupported!) - Center( - child: _LoginButton( - onPressed: controller.signUpAction, - icon: Icon( - CupertinoIcons.person_add, - color: Theme.of(context).textTheme.bodyText1!.color, - ), - labelText: L10n.of(context)!.register, - ), - ), - ], - ), - ), - ]), + ), + ), bottomNavigationBar: Material( elevation: 6, color: Theme.of(context).scaffoldBackgroundColor, - child: Wrap( - alignment: WrapAlignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - TextButton( - onPressed: () => launch(AppConfig.privacyUrl), - child: Text( - L10n.of(context)!.privacy, - style: const TextStyle( - decoration: TextDecoration.underline, - color: Colors.blueGrey, - ), - ), + ButtonBar( + children: [ + OutlinedButton( + onPressed: controller.domain != null + ? () => controller.setHomeserver(controller.domain!) + : null, + child: Text(L10n.of(context)!.selectServer), + ) + ], ), - TextButton( - onPressed: () => PlatformInfos.showDialog(context), - child: Text( - L10n.of(context)!.about, - style: const TextStyle( - decoration: TextDecoration.underline, - color: Colors.blueGrey, + Wrap( + alignment: WrapAlignment.center, + children: [ + TextButton( + onPressed: () => launch(AppConfig.privacyUrl), + child: Text( + L10n.of(context)!.privacy, + style: const TextStyle( + decoration: TextDecoration.underline, + color: Colors.blueGrey, + ), + ), ), - ), + TextButton( + onPressed: () => PlatformInfos.showDialog(context), + child: Text( + L10n.of(context)!.about, + style: const TextStyle( + decoration: TextDecoration.underline, + color: Colors.blueGrey, + ), + ), + ), + ], ), ], ), @@ -193,93 +171,3 @@ class HomeserverPickerView extends StatelessWidget { ); } } - -class _SsoButton extends StatelessWidget { - final IdentityProvider identityProvider; - final void Function()? onPressed; - const _SsoButton({ - Key? key, - required this.identityProvider, - this.onPressed, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(7), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Material( - color: Colors.white, - borderRadius: BorderRadius.circular(7), - clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.all(2.0), - child: identityProvider.icon == null - ? const Icon(Icons.web_outlined) - : CachedNetworkImage( - imageUrl: Uri.parse(identityProvider.icon!) - .getDownloadLink( - Matrix.of(context).getLoginClient()) - .toString(), - width: 32, - height: 32, - ), - ), - ), - const SizedBox(height: 8), - Text( - identityProvider.name ?? - identityProvider.brand ?? - L10n.of(context)!.singlesignon, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).textTheme.subtitle2!.color, - ), - ), - ], - ), - ), - ); - } -} - -class _LoginButton extends StatelessWidget { - final String? labelText; - final Widget? icon; - final void Function()? onPressed; - const _LoginButton({ - Key? key, - this.labelText, - this.icon, - this.onPressed, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return OutlinedButton.icon( - style: OutlinedButton.styleFrom( - minimumSize: const Size(256, 56), - textStyle: const TextStyle(fontWeight: FontWeight.bold), - backgroundColor: Theme.of(context).backgroundColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - ), - ), - onPressed: onPressed, - icon: icon!, - label: Text( - labelText!, - style: TextStyle( - color: Theme.of(context).textTheme.bodyText1!.color, - ), - ), - ); - } -} diff --git a/lib/pages/homeserver_picker/homeserver_tile.dart b/lib/pages/homeserver_picker/homeserver_tile.dart index 39cceefa..0ae87e5f 100644 --- a/lib/pages/homeserver_picker/homeserver_tile.dart +++ b/lib/pages/homeserver_picker/homeserver_tile.dart @@ -4,16 +4,29 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart'; import 'package:url_launcher/link.dart'; +import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; + class HomeserverTile extends StatelessWidget { final HomeserverBenchmarkResult benchmark; - final VoidCallback onSelect; + final HomeserverPickerController controller; const HomeserverTile( - {Key? key, required this.benchmark, required this.onSelect}) + {Key? key, required this.benchmark, required this.controller}) : super(key: key); + @override Widget build(BuildContext context) { + final domain = benchmark.homeserver.baseUrl.host; return ExpansionTile( + leading: Radio( + value: domain, + groupValue: controller.domain, + onChanged: controller.domain == domain + ? (s) => controller.unsetDomain + : controller.setDomain), + onExpansionChanged: controller.domain == domain + ? (o) => controller.unsetDomain() + : (o) => controller.setDomain(domain), title: Text(benchmark.homeserver.baseUrl.host), subtitle: benchmark.homeserver.description != null ? Text(benchmark.homeserver.description!) @@ -66,10 +79,6 @@ class HomeserverTile extends StatelessWidget { child: Text(L10n.of(context)!.serverRules), ), ), - OutlinedButton( - onPressed: onSelect.call, - child: Text(L10n.of(context)!.selectServer), - ), ], ), ], @@ -77,10 +86,32 @@ class HomeserverTile extends StatelessWidget { } } +class CustomHomeserverTile extends StatelessWidget { + final String domain; + final VoidCallback onSelect; + + const CustomHomeserverTile( + {Key? key, required this.domain, required this.onSelect}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(domain), + subtitle: Text(L10n.of(context)!.customHomeserver), + trailing: IconButton( + icon: const Icon(Icons.arrow_forward), + onPressed: onSelect, + ), + ); + } +} + class JoinMatrixAttributionTile extends StatelessWidget { final parser = JoinmatrixOrgParser(); JoinMatrixAttributionTile({Key? key}) : super(key: key); + @override Widget build(BuildContext context) { return ListTile( diff --git a/lib/pages/login/login.dart b/lib/pages/login/login.dart index 530fccaf..722b57de 100644 --- a/lib/pages/login/login.dart +++ b/lib/pages/login/login.dart @@ -13,14 +13,14 @@ import '../../utils/platform_infos.dart'; import 'login_view.dart'; class Login extends StatefulWidget { - const Login({Key? key}) : super(key: key); + final String username; + const Login({Key? key, required this.username}) : super(key: key); @override LoginController createState() => LoginController(); } class LoginController extends State { - final TextEditingController usernameController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); String? usernameError; String? passwordError; @@ -31,24 +31,19 @@ class LoginController extends State { void login([_]) async { final matrix = Matrix.of(context); - if (usernameController.text.isEmpty) { - setState(() => usernameError = L10n.of(context)!.pleaseEnterYourUsername); - } else { - setState(() => usernameError = null); - } if (passwordController.text.isEmpty) { setState(() => passwordError = L10n.of(context)!.pleaseEnterYourPassword); } else { setState(() => passwordError = null); } - if (usernameController.text.isEmpty || passwordController.text.isEmpty) { + if (passwordController.text.isEmpty) { return; } setState(() => loading = true); try { - final username = usernameController.text; + final username = widget.username; AuthenticationIdentifier identifier; if (username.isEmail) { identifier = AuthenticationThirdPartyIdentifier( @@ -160,8 +155,7 @@ class LoginController extends State { fullyCapitalizedForMaterial: false, textFields: [ DialogTextField( - initialText: - usernameController.text.isEmail ? usernameController.text : '', + initialText: widget.username.isEmail ? widget.username : '', hintText: L10n.of(context)!.enterAnEmailAddress, keyboardType: TextInputType.emailAddress, ), @@ -228,7 +222,6 @@ class LoginController extends State { if (success.error == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context)!.passwordHasBeenChanged))); - usernameController.text = input.single; passwordController.text = password.single; login(); } diff --git a/lib/pages/login/login_view.dart b/lib/pages/login/login_view.dart index affd8323..05b048c2 100644 --- a/lib/pages/login/login_view.dart +++ b/lib/pages/login/login_view.dart @@ -36,24 +36,6 @@ class LoginView extends StatelessWidget { readOnly: controller.loading, autocorrect: false, autofocus: true, - onChanged: controller.checkWellKnownWithCoolDown, - controller: controller.usernameController, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.emailAddress, - autofillHints: - controller.loading ? null : [AutofillHints.username], - decoration: InputDecoration( - prefixIcon: const Icon(Icons.account_box_outlined), - hintText: L10n.of(context)!.emailOrUsername, - errorText: controller.usernameError, - labelText: L10n.of(context)!.emailOrUsername), - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: TextField( - readOnly: controller.loading, - autocorrect: false, autofillHints: controller.loading ? null : [AutofillHints.password], controller: controller.passwordController, diff --git a/lib/pages/sign_up/signup.dart b/lib/pages/sign_up/signup.dart index a9df0d37..eba89c79 100644 --- a/lib/pages/sign_up/signup.dart +++ b/lib/pages/sign_up/signup.dart @@ -9,14 +9,16 @@ import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/localized_exception_extension.dart'; class SignupPage extends StatefulWidget { - const SignupPage({Key? key}) : super(key: key); + final String? username; + final VoidCallback? onRegistrationComplete; + const SignupPage({Key? key, this.username, this.onRegistrationComplete}) + : super(key: key); @override SignupPageController createState() => SignupPageController(); } class SignupPageController extends State { - final TextEditingController usernameController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); final TextEditingController passwordController2 = TextEditingController(); final TextEditingController emailController = TextEditingController(); @@ -26,19 +28,12 @@ class SignupPageController extends State { void toggleShowPassword() => setState(() => showPassword = !showPassword); - String? get domain => VRouter.of(context).queryParameters['domain']; + String? get domain => Matrix.of(context).getLoginClient().homeserver!.host; + String? get username => + widget.username ?? VRouter.of(context).queryParameters['username']; final GlobalKey formKey = GlobalKey(); - String? usernameTextFieldValidator(String? value) { - usernameController.text = - usernameController.text.trim().toLowerCase().replaceAll(' ', '_'); - if (value!.isEmpty) { - return L10n.of(context)!.pleaseChooseAUsername; - } - return null; - } - String? password1TextFieldValidator(String? value) { const minLength = 8; if (value!.isEmpty) { @@ -92,12 +87,13 @@ class SignupPageController extends State { } await client.uiaRequestBackground( (auth) => client.register( - username: usernameController.text, + username: username!, password: passwordController.text, initialDeviceDisplayName: PlatformInfos.clientName, auth: auth, ), ); + widget.onRegistrationComplete?.call(); } catch (e) { error = (e).toLocalizedString(context); } finally { diff --git a/lib/pages/sign_up/signup_view.dart b/lib/pages/sign_up/signup_view.dart index 4cbd4492..a661a4d8 100644 --- a/lib/pages/sign_up/signup_view.dart +++ b/lib/pages/sign_up/signup_view.dart @@ -7,6 +7,7 @@ import 'signup.dart'; class SignupPageView extends StatelessWidget { final SignupPageController controller; + const SignupPageView(this.controller, {Key? key}) : super(key: key); @override @@ -14,36 +15,19 @@ class SignupPageView extends StatelessWidget { return OnePageCard( child: Scaffold( appBar: AppBar( - title: Text(L10n.of(context)!.signUp), + title: Text(L10n.of(context)!.signUpAs(controller.username!)), ), body: Form( key: controller.formKey, child: ListView( children: [ + ListTile(title: Text(L10n.of(context)!.inventPassword)), Padding( padding: const EdgeInsets.all(12.0), child: TextFormField( readOnly: controller.loading, autocorrect: false, - controller: controller.usernameController, - autofillHints: - controller.loading ? null : [AutofillHints.username], - validator: controller.usernameTextFieldValidator, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.account_circle_outlined), - hintText: L10n.of(context)!.username, - labelText: L10n.of(context)!.username, - prefixText: '@', - prefixStyle: const TextStyle(fontWeight: FontWeight.bold), - suffixStyle: const TextStyle(fontWeight: FontWeight.w200), - suffixText: ':${controller.domain}'), - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: TextFormField( - readOnly: controller.loading, - autocorrect: false, + autofocus: true, autofillHints: controller.loading ? null : [AutofillHints.password], controller: controller.passwordController, @@ -80,6 +64,7 @@ class SignupPageView extends StatelessWidget { ), ), ), + const Divider(), Padding( padding: const EdgeInsets.all(12.0), child: TextFormField( @@ -93,7 +78,8 @@ class SignupPageView extends StatelessWidget { decoration: InputDecoration( prefixIcon: const Icon(Icons.mail_outlined), labelText: L10n.of(context)!.addEmail, - hintText: 'email@example.abc', + hintText: L10n.of(context)!.sampleEmail, + helperText: L10n.of(context)!.emailHelper, ), ), ),