diff --git a/assets/info-logo.png b/assets/info-logo.png index 2808d732..ceb42f09 100644 Binary files a/assets/info-logo.png and b/assets/info-logo.png differ diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index d1239cdb..8001d821 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2473,5 +2473,8 @@ "jump": "Jump", "openLinkInBrowser": "Open link in browser", "reportErrorDescription": "Oh no. Something went wrong. Please try again later. If you want, you can report the bug to the developers.", - "report": "report" + "report": "report", + "signInWithPassword": "Sign in with password", + "continueWith": "Continue with:", + "pleaseTryAgainLaterOrChooseDifferentServer": "Please try again later or choose a different server." } diff --git a/assets/login_wallpaper.png b/assets/login_wallpaper.png index 2ca8c39e..2ca9db9c 100644 Binary files a/assets/login_wallpaper.png and b/assets/login_wallpaper.png differ diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 92fd66cb..46671c30 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -9,7 +9,6 @@ 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_page.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'; @@ -27,7 +26,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'; @@ -266,23 +264,6 @@ class AppRoutes { widget: const Login(), buildTransition: _fadeTransition, ), - VWidget( - 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', widget: const LogViewer(), @@ -358,23 +339,6 @@ class AppRoutes { widget: const Login(), buildTransition: _fadeTransition, ), - VWidget( - 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( diff --git a/lib/pages/connect/connect_page.dart b/lib/pages/connect/connect_page.dart deleted file mode 100644 index c58f48c6..00000000 --- a/lib/pages/connect/connect_page.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_web_auth_2/flutter_web_auth_2.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_page_view.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class ConnectPage extends StatefulWidget { - const ConnectPage({Key? key}) : super(key: key); - - @override - State createState() => ConnectPageController(); -} - -class ConnectPageController extends State { - final TextEditingController usernameController = TextEditingController(); - String? signupError; - bool loading = false; - - void pickAvatar() async { - final source = !PlatformInfos.isMobile - ? ImageSource.gallery - : await showModalActionSheet( - context: context, - title: L10n.of(context)!.changeYourAvatar, - actions: [ - SheetAction( - key: ImageSource.camera, - label: L10n.of(context)!.openCamera, - isDefaultAction: true, - icon: Icons.camera_alt_outlined, - ), - SheetAction( - key: ImageSource.gallery, - label: L10n.of(context)!.openGallery, - icon: Icons.photo_outlined, - ), - ], - ); - if (source == null) return; - final picked = await ImagePicker().pickImage( - source: source, - imageQuality: 50, - maxWidth: 512, - maxHeight: 512, - ); - setState(() { - Matrix.of(context).loginAvatar = picked; - }); - } - - void signUp() async { - usernameController.text = usernameController.text.trim(); - final localpart = - usernameController.text.toLowerCase().replaceAll(' ', '_'); - if (localpart.isEmpty) { - setState(() { - signupError = L10n.of(context)!.pleaseChooseAUsername; - }); - return; - } - - setState(() { - signupError = null; - loading = true; - }); - - try { - try { - await Matrix.of(context).getLoginClient().register(username: localpart); - } on MatrixException catch (e) { - if (!e.requireAdditionalAuthentication) rethrow; - } - setState(() { - loading = false; - }); - Matrix.of(context).loginUsername = usernameController.text; - VRouter.of(context).to('signup'); - } catch (e, s) { - Logs().d('Sign up failed', e, s); - setState(() { - signupError = e.toLocalizedString(context); - loading = false; - }); - } - } - - bool _supportsFlow(String flowType) => - Matrix.of(context) - .loginHomeserverSummary - ?.loginFlows - .any((flow) => flow.type == flowType) ?? - false; - - bool get supportsSso => _supportsFlow('m.login.sso'); - - bool isDefaultPlatform = - (PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS); - - bool get supportsLogin => _supportsFlow('m.login.password'); - - void login() => VRouter.of(context).to('login'); - - Map? _rawLoginTypes; - - List? get identityProviders { - final loginTypes = _rawLoginTypes; - if (loginTypes == null) return null; - final rawProviders = loginTypes.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; - } - - void ssoLoginAction(String id) async { - final redirectUrl = kIsWeb - ? '${html.window.origin!}/web/auth.html' - : isDefaultPlatform - ? '${AppConfig.appOpenUrlScheme.toLowerCase()}://login' - : 'http://localhost:3001//login'; - final url = - '${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'; - final urlScheme = isDefaultPlatform - ? Uri.parse(redirectUrl).scheme - : "http://localhost:3001"; - final result = await FlutterWebAuth2.authenticate( - url: url, - callbackUrlScheme: urlScheme, - ); - final token = Uri.parse(result).queryParameters['loginToken']; - if (token?.isEmpty ?? false) return; - - await showFutureLoadingDialog( - context: context, - future: () => Matrix.of(context).getLoginClient().login( - LoginType.mLoginToken, - token: token, - initialDeviceDisplayName: PlatformInfos.clientName, - ), - ); - } - - @override - void initState() { - super.initState(); - if (supportsSso) { - Matrix.of(context) - .getLoginClient() - .request( - RequestType.GET, - '/client/r0/login', - ) - .then( - (loginTypes) => setState(() { - _rawLoginTypes = loginTypes; - }), - ); - } - } - - @override - Widget build(BuildContext context) => ConnectPageView(this); -} - -class IdentityProvider { - final String? id; - final String? name; - final String? icon; - final String? brand; - - IdentityProvider({this.id, this.name, this.icon, this.brand}); - - factory IdentityProvider.fromJson(Map json) => - IdentityProvider( - id: json['id'], - name: json['name'], - icon: json['icon'], - brand: json['brand'], - ); -} diff --git a/lib/pages/connect/connect_page_view.dart b/lib/pages/connect/connect_page_view.dart deleted file mode 100644 index fe21c1f6..00000000 --- a/lib/pages/connect/connect_page_view.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pages/connect/connect_page.dart'; -import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'sso_button.dart'; - -class ConnectPageView extends StatelessWidget { - final ConnectPageController controller; - const ConnectPageView(this.controller, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final avatar = Matrix.of(context).loginAvatar; - final identityProviders = controller.identityProviders; - return LoginScaffold( - appBar: AppBar( - leading: controller.loading ? null : const BackButton(), - automaticallyImplyLeading: !controller.loading, - centerTitle: true, - title: Text( - Matrix.of(context).getLoginClient().homeserver?.host ?? '', - ), - ), - body: ListView( - key: const Key('ConnectPageListView'), - children: [ - if (Matrix.of(context).loginRegistrationSupported ?? false) ...[ - Padding( - padding: const EdgeInsets.all(12.0), - child: Center( - child: Stack( - children: [ - Material( - borderRadius: BorderRadius.circular(64), - elevation: Theme.of(context) - .appBarTheme - .scrolledUnderElevation ?? - 10, - color: Colors.transparent, - shadowColor: Theme.of(context) - .colorScheme - .onBackground - .withAlpha(64), - clipBehavior: Clip.hardEdge, - child: CircleAvatar( - radius: 64, - backgroundColor: Colors.white, - child: avatar == null - ? const Icon( - Icons.person, - color: Colors.black, - size: 64, - ) - : FutureBuilder( - future: avatar.readAsBytes(), - builder: (context, snapshot) { - final bytes = snapshot.data; - if (bytes == null) { - return const CircularProgressIndicator - .adaptive(); - } - return Image.memory( - bytes, - fit: BoxFit.cover, - width: 128, - height: 128, - ); - }, - ), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: FloatingActionButton( - mini: true, - onPressed: controller.pickAvatar, - backgroundColor: Colors.white, - foregroundColor: Colors.black, - child: const Icon(Icons.camera_alt_outlined), - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: TextField( - controller: controller.usernameController, - onSubmitted: (_) => controller.signUp(), - decoration: InputDecoration( - prefixIcon: const Icon(Icons.account_box_outlined), - hintText: L10n.of(context)!.chooseAUsername, - errorText: controller.signupError, - errorStyle: const TextStyle(color: Colors.orange), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: Hero( - tag: 'loginButton', - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - ), - onPressed: controller.loading ? () {} : controller.signUp, - icon: const Icon(Icons.person_add_outlined), - label: controller.loading - ? const LinearProgressIndicator() - : Text(L10n.of(context)!.signUp), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - children: [ - Expanded( - child: Divider( - thickness: 1, - color: Theme.of(context).dividerColor, - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - L10n.of(context)!.or, - style: const TextStyle(fontSize: 18), - ), - ), - Expanded( - child: Divider( - thickness: 1, - color: Theme.of(context).dividerColor, - ), - ), - ], - ), - ), - ], - if (controller.supportsSso) - identityProviders == null - ? const SizedBox( - height: 74, - child: Center(child: CircularProgressIndicator.adaptive()), - ) - : Center( - child: identityProviders.length == 1 - ? Container( - width: double.infinity, - padding: const EdgeInsets.all(12.0), - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context) - .colorScheme - .primaryContainer, - foregroundColor: Theme.of(context) - .colorScheme - .onPrimaryContainer, - ), - icon: identityProviders.single.icon == null - ? const Icon( - Icons.web_outlined, - size: 16, - ) - : Image.network( - Uri.parse(identityProviders.single.icon!) - .getDownloadLink( - Matrix.of(context).getLoginClient(), - ) - .toString(), - width: 32, - height: 32, - ), - onPressed: () => controller - .ssoLoginAction(identityProviders.single.id!), - label: Text( - identityProviders.single.name ?? - identityProviders.single.brand ?? - L10n.of(context)!.loginWithOneClick, - ), - ), - ) - : Wrap( - children: [ - for (final identityProvider in identityProviders) - SsoButton( - onPressed: () => controller - .ssoLoginAction(identityProvider.id!), - identityProvider: identityProvider, - ), - ].toList(), - ), - ), - if (controller.supportsLogin) - Padding( - padding: const EdgeInsets.all(12.0), - child: Hero( - tag: 'signinButton', - child: ElevatedButton.icon( - icon: const Icon(Icons.login_outlined), - style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, - foregroundColor: - Theme.of(context).colorScheme.onPrimaryContainer, - ), - onPressed: controller.loading ? () {} : controller.login, - label: Text(L10n.of(context)!.login), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/connect/sso_button.dart b/lib/pages/connect/sso_button.dart deleted file mode 100644 index f8fe1e16..00000000 --- a/lib/pages/connect/sso_button.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pages/connect/connect_page.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -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: 10.0, vertical: 6.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Material( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: identityProvider.icon == null - ? const Icon(Icons.web_outlined) - : Image.network( - 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: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/homeserver_picker/homeserver_app_bar.dart b/lib/pages/homeserver_picker/homeserver_app_bar.dart index f9d2c19a..82d25870 100644 --- a/lib/pages/homeserver_picker/homeserver_app_bar.dart +++ b/lib/pages/homeserver_picker/homeserver_app_bar.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:matrix_homeserver_recommendations/matrix_homeserver_recommendations.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'homeserver_bottom_sheet.dart'; import 'homeserver_picker.dart'; class HomeserverAppBar extends StatelessWidget { @@ -13,25 +16,57 @@ class HomeserverAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - return TextField( - focusNode: controller.homeserverFocusNode, - controller: controller.homeserverController, - onChanged: controller.onChanged, - decoration: InputDecoration( - prefixIcon: Navigator.of(context).canPop() - ? IconButton( - onPressed: Navigator.of(context).pop, - icon: const Icon(Icons.arrow_back), - ) - : null, - prefixText: '${L10n.of(context)!.homeserver}: ', - hintText: L10n.of(context)!.enterYourHomeserver, - suffixIcon: const Icon(Icons.search), - errorText: controller.error, + return TypeAheadField( + suggestionsBoxDecoration: SuggestionsBoxDecoration( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + elevation: Theme.of(context).appBarTheme.scrolledUnderElevation ?? 4, + shadowColor: Theme.of(context).appBarTheme.shadowColor ?? Colors.black, + constraints: const BoxConstraints(maxHeight: 256), + ), + itemBuilder: (context, homeserver) => ListTile( + title: Text(homeserver.homeserver.baseUrl.toString()), + subtitle: Text(homeserver.homeserver.description ?? ''), + trailing: IconButton( + icon: const Icon(Icons.info_outlined), + onPressed: () => showModalBottomSheet( + context: context, + builder: (_) => HomeserverBottomSheet( + homeserver: homeserver, + ), + ), + ), + ), + suggestionsCallback: (pattern) async { + final homeserverList = + await const JoinmatrixOrgParser().fetchHomeservers(); + final benchmark = await HomeserverListProvider.benchmarkHomeserver( + homeserverList, + timeout: const Duration(seconds: 3), + ); + return benchmark; + }, + onSuggestionSelected: (suggestion) { + controller.homeserverController.text = + suggestion.homeserver.baseUrl.host; + controller.checkHomeserverAction(); + }, + textFieldConfiguration: TextFieldConfiguration( + controller: controller.homeserverController, + decoration: InputDecoration( + prefixIcon: Navigator.of(context).canPop() + ? IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon(Icons.arrow_back), + ) + : null, + prefixText: '${L10n.of(context)!.homeserver}: ', + hintText: L10n.of(context)!.enterYourHomeserver, + suffixIcon: const Icon(Icons.search), + ), + textInputAction: TextInputAction.search, + onSubmitted: controller.checkHomeserverAction, + autocorrect: false, ), - readOnly: !AppConfig.allowOtherHomeservers, - onSubmitted: (_) => controller.checkHomeserverAction(), - autocorrect: false, ); } } diff --git a/lib/pages/homeserver_picker/homeserver_picker.dart b/lib/pages/homeserver_picker/homeserver_picker.dart index 89c08970..d8d73e7c 100644 --- a/lib/pages/homeserver_picker/homeserver_picker.dart +++ b/lib/pages/homeserver_picker/homeserver_picker.dart @@ -7,16 +7,16 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:hive_flutter/hive_flutter.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_bottom_sheet.dart'; import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view.dart'; -import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/localized_exception_extension.dart'; @@ -35,14 +35,8 @@ class HomeserverPickerController extends State { final TextEditingController homeserverController = TextEditingController( text: AppConfig.defaultHomeserver, ); - final FocusNode homeserverFocusNode = FocusNode(); - String? error; - List? benchmarkResults; - bool displayServerList = false; - bool get loadingHomeservers => - AppConfig.allowOtherHomeservers && benchmarkResults == null; - String searchTerm = ''; + String? error; bool isTorBrowser = false; @@ -65,98 +59,34 @@ class HomeserverPickerController extends State { isTorBrowser = isTor; } - void _updateFocus() { - if (benchmarkResults == null) _loadHomeserverList(); - if (homeserverFocusNode.hasFocus) { - setState(() { - displayServerList = true; - }); - } - } - - void showServerInfo(HomeserverBenchmarkResult server) => - showAdaptiveBottomSheet( - context: context, - builder: (_) => HomeserverBottomSheet( - homeserver: server, - ), - ); - - void onChanged(String text) => setState(() { - searchTerm = text; - }); - - List get filteredHomeservers => benchmarkResults! - .where( - (element) => - element.homeserver.baseUrl.host.contains(searchTerm) || - (element.homeserver.description?.contains(searchTerm) ?? false), - ) - .toList(); - - void _loadHomeserverList() async { - try { - final homeserverList = - await const JoinmatrixOrgParser().fetchHomeservers(); - final benchmark = await HomeserverListProvider.benchmarkHomeserver( - homeserverList, - timeout: const Duration(seconds: 10), - ); - if (!mounted) return; - setState(() { - benchmarkResults = benchmark; - }); - } catch (e, s) { - Logs().e('Homeserver benchmark failed', e, s); - benchmarkResults = []; - } - } - - void setServer(String server) => setState(() { - homeserverController.text = server; - searchTerm = ''; - homeserverFocusNode.unfocus(); - displayServerList = false; - }); + String? _lastCheckedUrl; /// 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 { + Future checkHomeserverAction([_]) async { + homeserverController.text = + homeserverController.text.trim().toLowerCase().replaceAll(' ', '-'); + if (homeserverController.text == _lastCheckedUrl) return; + _lastCheckedUrl = homeserverController.text; setState(() { - homeserverFocusNode.unfocus(); - error = null; + error = _rawLoginTypes = loginHomeserverSummary = null; isLoading = true; - searchTerm = ''; - displayServerList = false; }); try { - homeserverController.text = - homeserverController.text.trim().toLowerCase().replaceAll(' ', '-'); var homeserver = Uri.parse(homeserverController.text); if (homeserver.scheme.isEmpty) { homeserver = Uri.https(homeserverController.text, ''); } - final matrix = Matrix.of(context); - matrix.loginHomeserverSummary = - await matrix.getLoginClient().checkHomeserver(homeserver); - final ssoSupported = matrix.loginHomeserverSummary!.loginFlows - .any((flow) => flow.type == 'm.login.sso'); - - try { - await Matrix.of(context).getLoginClient().register(); - matrix.loginRegistrationSupported = true; - } on MatrixException catch (e) { - matrix.loginRegistrationSupported = e.requireAdditionalAuthentication; - } - - if (!ssoSupported && matrix.loginRegistrationSupported == false) { - // Server does not support SSO or registration. We can skip to login page: - VRouter.of(context).to('login'); - } else { - VRouter.of(context).to('connect'); + final client = Matrix.of(context).getLoginClient(); + loginHomeserverSummary = await client.checkHomeserver(homeserver); + if (supportsSso) { + _rawLoginTypes = await client.request( + RequestType.GET, + '/client/r0/login', + ); } } catch (e) { setState(() => error = (e).toLocalizedString(context)); @@ -167,17 +97,71 @@ class HomeserverPickerController extends State { } } - @override - void dispose() { - homeserverFocusNode.removeListener(_updateFocus); - super.dispose(); + HomeserverSummary? loginHomeserverSummary; + + bool _supportsFlow(String flowType) => + loginHomeserverSummary?.loginFlows.any((flow) => flow.type == flowType) ?? + false; + + bool get supportsSso => _supportsFlow('m.login.sso'); + + bool isDefaultPlatform = + (PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS); + + bool get supportsPasswordLogin => _supportsFlow('m.login.password'); + + Map? _rawLoginTypes; + + void ssoLoginAction(String id) async { + final redirectUrl = kIsWeb + ? '${html.window.origin!}/web/auth.html' + : isDefaultPlatform + ? '${AppConfig.appOpenUrlScheme.toLowerCase()}://login' + : 'http://localhost:3001//login'; + final url = + '${Matrix.of(context).getLoginClient().homeserver?.toString()}/_matrix/client/r0/login/sso/redirect/${Uri.encodeComponent(id)}?redirectUrl=${Uri.encodeQueryComponent(redirectUrl)}'; + final urlScheme = isDefaultPlatform + ? Uri.parse(redirectUrl).scheme + : "http://localhost:3001"; + final result = await FlutterWebAuth2.authenticate( + url: url, + callbackUrlScheme: urlScheme, + ); + final token = Uri.parse(result).queryParameters['loginToken']; + if (token?.isEmpty ?? false) return; + + await showFutureLoadingDialog( + context: context, + future: () => Matrix.of(context).getLoginClient().login( + LoginType.mLoginToken, + token: token, + initialDeviceDisplayName: PlatformInfos.clientName, + ), + ); } + List? get identityProviders { + final loginTypes = _rawLoginTypes; + if (loginTypes == null) return null; + final rawProviders = loginTypes.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; + } + + void login() => VRouter.of(context).to('login'); + @override void initState() { - homeserverFocusNode.addListener(_updateFocus); _checkTorBrowser(); super.initState(); + WidgetsBinding.instance.addPostFrameCallback(checkHomeserverAction); } @override @@ -204,3 +188,20 @@ class HomeserverPickerController extends State { ); } } + +class IdentityProvider { + final String? id; + final String? name; + final String? icon; + final String? brand; + + IdentityProvider({this.id, this.name, this.icon, this.brand}); + + factory IdentityProvider.fromJson(Map json) => + IdentityProvider( + id: json['id'], + name: json['name'], + icon: json['icon'], + brand: json['brand'], + ); +} diff --git a/lib/pages/homeserver_picker/homeserver_picker_view.dart b/lib/pages/homeserver_picker/homeserver_picker_view.dart index 47d79d6c..47f45e70 100644 --- a/lib/pages/homeserver_picker/homeserver_picker_view.dart +++ b/lib/pages/homeserver_picker/homeserver_picker_view.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; import '../../config/themes.dart'; +import '../../widgets/mxc_image.dart'; import 'homeserver_app_bar.dart'; import 'homeserver_picker.dart'; @@ -16,15 +16,19 @@ class HomeserverPickerView extends StatelessWidget { @override Widget build(BuildContext context) { - final benchmarkResults = controller.benchmarkResults; + final identityProviders = controller.identityProviders; + final errorText = controller.error; return LoginScaffold( + appBar: AppBar( + titleSpacing: 12, + title: Padding( + padding: const EdgeInsets.all(0.0), + child: HomeserverAppBar(controller: controller), + ), + ), body: SafeArea( child: Column( children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: HomeserverAppBar(controller: controller), - ), // display a prominent banner to import session for TOR browser // users. This feature is just some UX sugar as TOR users are // usually forced to logout as TOR browser is non-persistent @@ -49,108 +53,119 @@ class HomeserverPickerView extends StatelessWidget { ), ), Expanded( - child: controller.displayServerList - ? ListView( + child: controller.isLoading + ? const Center(child: CircularProgressIndicator.adaptive()) + : ListView( children: [ - if (controller.displayServerList) - Padding( - padding: const EdgeInsets.all(12.0), - child: Material( - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), - color: Theme.of(context) - .colorScheme - .onInverseSurface, - clipBehavior: Clip.hardEdge, - child: benchmarkResults == null - ? const Center( - child: Padding( - padding: EdgeInsets.all(12.0), - child: CircularProgressIndicator - .adaptive(), - ), - ) - : Column( - children: controller.filteredHomeservers - .map( - (server) => ListTile( - trailing: IconButton( - icon: const Icon( - Icons.info_outlined, - color: Colors.black, - ), - onPressed: () => controller - .showServerInfo(server), - ), - onTap: () => controller.setServer( - server.homeserver.baseUrl.host, - ), - title: Text( - server.homeserver.baseUrl.host, - style: const TextStyle( - color: Colors.black, - ), - ), - subtitle: Text( - server.homeserver.description ?? - '', - style: TextStyle( - color: Colors.grey.shade700, - ), - ), - ), - ) - .toList(), - ), + Image.asset( + 'assets/info-logo.png', + height: 96, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + children: [ + Expanded( + child: Divider( + thickness: 1, + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + Padding( + padding: const EdgeInsets.all(12.0), + child: Text( + L10n.of(context)!.continueWith, + style: const TextStyle(fontSize: 12), + ), + ), + Expanded( + child: Divider( + thickness: 1, + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + ], + ), + ), + if (errorText != null) ...[ + const Center( + child: Icon( + Icons.error_outline, + size: 48, + color: Colors.orange, ), ), - ], - ) - : Container( - alignment: Alignment.topCenter, - child: Image.asset( - 'assets/banner_transparent.png', - filterQuality: FilterQuality.medium, - ), - ), - ), - SafeArea( - child: Container( - padding: const EdgeInsets.all(12), - width: double.infinity, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextButton( - onPressed: () => launchUrlString(AppConfig.privacyUrl), - child: Text(L10n.of(context)!.privacy), - ), - TextButton( - onPressed: controller.restoreBackup, - child: Text(L10n.of(context)!.hydrate), - ), - Hero( - tag: 'loginButton', - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primary, - foregroundColor: - Theme.of(context).colorScheme.onPrimary, + const SizedBox(height: 12), + Center( + child: Text( + errorText, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 18, + ), + ), + ), + Center( + child: Text( + L10n.of(context)! + .pleaseTryAgainLaterOrChooseDifferentServer, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + const SizedBox(height: 12), + ], + if (identityProviders != null) ...[ + ...identityProviders.map( + (provider) => _LoginButton( + icon: provider.icon == null + ? const Icon(Icons.open_in_new_outlined) + : Material( + color: Colors.white, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + clipBehavior: Clip.hardEdge, + child: MxcImage( + placeholder: (_) => + const Icon(Icons.web_outlined), + uri: Uri.parse(provider.icon!), + width: 24, + height: 24, + ), + ), + label: provider.name ?? + provider.brand ?? + L10n.of(context)!.singlesignon, + onPressed: () => + controller.ssoLoginAction(provider.id!), + ), + ), + ], + if (controller.supportsPasswordLogin) + _LoginButton( + onPressed: controller.login, + icon: const Icon(Icons.login_outlined), + label: L10n.of(context)!.signInWithPassword, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: controller.restoreBackup, + child: Text(L10n.of(context)!.hydrate), + ), ), - onPressed: controller.isLoading - ? null - : controller.checkHomeserverAction, - icon: const Icon(Icons.start_outlined), - label: controller.isLoading - ? const LinearProgressIndicator() - : Text(L10n.of(context)!.letsStart), - ), + ], ), - ], - ), - ), ), ], ), @@ -158,3 +173,33 @@ class HomeserverPickerView extends StatelessWidget { ); } } + +class _LoginButton extends StatelessWidget { + final Widget icon; + final String label; + final void Function() onPressed; + + const _LoginButton({ + required this.icon, + required this.label, + required this.onPressed, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + margin: const EdgeInsets.only(bottom: 16), + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: onPressed, + icon: icon, + label: Text(label), + ), + ); + } +} diff --git a/lib/pages/sign_up/signup.dart b/lib/pages/sign_up/signup.dart deleted file mode 100644 index e4a4d773..00000000 --- a/lib/pages/sign_up/signup.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:vrouter/vrouter.dart'; - -import 'package:fluffychat/pages/sign_up/signup_view.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../utils/localized_exception_extension.dart'; - -class SignupPage extends StatefulWidget { - const SignupPage({Key? key}) : super(key: key); - - @override - SignupPageController createState() => SignupPageController(); -} - -class SignupPageController extends State { - final TextEditingController passwordController = TextEditingController(); - final TextEditingController password2Controller = TextEditingController(); - final TextEditingController emailController = TextEditingController(); - String? error; - bool loading = false; - bool showPassword = false; - bool noEmailWarningConfirmed = false; - bool displaySecondPasswordField = false; - - static const int minPassLength = 8; - - void toggleShowPassword() => setState(() => showPassword = !showPassword); - - String? get domain => VRouter.of(context).queryParameters['domain']; - - final GlobalKey formKey = GlobalKey(); - - void onPasswordType(String text) { - if (text.length >= minPassLength && !displaySecondPasswordField) { - setState(() { - displaySecondPasswordField = true; - }); - } - } - - String? password1TextFieldValidator(String? value) { - if (value!.isEmpty) { - return L10n.of(context)!.chooseAStrongPassword; - } - if (value.length < minPassLength) { - return L10n.of(context)! - .pleaseChooseAtLeastChars(minPassLength.toString()); - } - return null; - } - - String? password2TextFieldValidator(String? value) { - if (value!.isEmpty) { - return L10n.of(context)!.repeatPassword; - } - if (value != passwordController.text) { - return L10n.of(context)!.passwordsDoNotMatch; - } - return null; - } - - String? emailTextFieldValidator(String? value) { - if (value!.isEmpty && !noEmailWarningConfirmed) { - noEmailWarningConfirmed = true; - return L10n.of(context)!.noEmailWarning; - } - 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, - ); - } - - final displayname = Matrix.of(context).loginUsername!; - final localPart = displayname.toLowerCase().replaceAll(' ', '_'); - - await client.uiaRequestBackground( - (auth) => client.register( - username: localPart, - password: passwordController.text, - initialDeviceDisplayName: PlatformInfos.clientName, - auth: auth, - ), - ); - // Set displayname - if (displayname != localPart) { - await client.setDisplayName( - client.userID!, - displayname, - ); - } - } catch (e) { - error = (e).toLocalizedString(context); - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } - - @override - Widget build(BuildContext context) => SignupPageView(this); -} diff --git a/lib/pages/sign_up/signup_view.dart b/lib/pages/sign_up/signup_view.dart deleted file mode 100644 index 698cff44..00000000 --- a/lib/pages/sign_up/signup_view.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; -import 'signup.dart'; - -class SignupPageView extends StatelessWidget { - final SignupPageController controller; - const SignupPageView(this.controller, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return LoginScaffold( - appBar: AppBar( - leading: controller.loading ? null : const BackButton(), - automaticallyImplyLeading: !controller.loading, - title: Text(L10n.of(context)!.signUp), - ), - body: Form( - key: controller.formKey, - child: ListView( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: TextFormField( - readOnly: controller.loading, - autocorrect: false, - onChanged: controller.onPasswordType, - autofillHints: - controller.loading ? null : [AutofillHints.newPassword], - controller: controller.passwordController, - obscureText: !controller.showPassword, - validator: controller.password1TextFieldValidator, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.vpn_key_outlined), - suffixIcon: IconButton( - tooltip: L10n.of(context)!.showPassword, - icon: Icon( - controller.showPassword - ? Icons.visibility_off_outlined - : Icons.visibility_outlined, - color: Colors.black, - ), - onPressed: controller.toggleShowPassword, - ), - errorStyle: const TextStyle(color: Colors.orange), - hintText: L10n.of(context)!.chooseAStrongPassword, - ), - ), - ), - if (controller.displaySecondPasswordField) - Padding( - padding: const EdgeInsets.all(12.0), - child: TextFormField( - readOnly: controller.loading, - autocorrect: false, - autofillHints: - controller.loading ? null : [AutofillHints.newPassword], - controller: controller.password2Controller, - obscureText: !controller.showPassword, - validator: controller.password2TextFieldValidator, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.repeat_outlined), - hintText: L10n.of(context)!.repeatPassword, - errorStyle: const TextStyle(color: Colors.orange), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: TextFormField( - readOnly: controller.loading, - autocorrect: false, - controller: controller.emailController, - keyboardType: TextInputType.emailAddress, - autofillHints: - controller.loading ? null : [AutofillHints.username], - validator: controller.emailTextFieldValidator, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.mail_outlined), - hintText: L10n.of(context)!.enterAnEmailAddress, - errorText: controller.error, - errorMaxLines: 4, - errorStyle: TextStyle( - color: controller.emailController.text.isEmpty - ? Colors.orangeAccent - : Colors.orange, - ), - ), - ), - ), - Hero( - tag: 'loginButton', - child: Padding( - padding: const EdgeInsets.all(12), - child: ElevatedButton.icon( - icon: const Icon(Icons.person_add_outlined), - style: ElevatedButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onPrimary, - backgroundColor: Theme.of(context).colorScheme.primary, - ), - onPressed: controller.loading ? () {} : controller.signup, - label: controller.loading - ? const LinearProgressIndicator() - : Text(L10n.of(context)!.signUp), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/layouts/login_scaffold.dart b/lib/widgets/layouts/login_scaffold.dart index 606b93e7..481b63f4 100644 --- a/lib/widgets/layouts/login_scaffold.dart +++ b/lib/widgets/layouts/login_scaffold.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; class LoginScaffold extends StatelessWidget { final Widget body; @@ -17,6 +21,7 @@ class LoginScaffold extends StatelessWidget { Widget build(BuildContext context) { final isMobileMode = !FluffyThemes.isColumnMode(context); final scaffold = Scaffold( + key: const Key('LoginScaffold'), backgroundColor: isMobileMode ? null : Colors.transparent, appBar: appBar == null ? null @@ -33,31 +38,102 @@ class LoginScaffold extends StatelessWidget { extendBodyBehindAppBar: true, extendBody: true, body: body, + bottomNavigationBar: isMobileMode + ? Material( + color: Theme.of(context).colorScheme.onInverseSurface, + child: const _PrivacyButtons( + mainAxisAlignment: MainAxisAlignment.center, + ), + ) + : null, ); if (isMobileMode) return scaffold; + final colorScheme = Theme.of(context).colorScheme; return Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/login_wallpaper.png'), - fit: BoxFit.cover, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + colors: [ + colorScheme.primaryContainer.withAlpha(64), + colorScheme.secondaryContainer.withAlpha(64), + colorScheme.tertiaryContainer.withAlpha(64), + colorScheme.primaryContainer.withAlpha(64), + ], ), ), - child: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Material( - color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.925), - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - clipBehavior: Clip.hardEdge, - elevation: 10, - shadowColor: Colors.black, - child: ConstrainedBox( - constraints: isMobileMode - ? const BoxConstraints() - : const BoxConstraints(maxWidth: 480, maxHeight: 640), - child: scaffold, + child: Column( + children: [ + const SizedBox(height: 64), + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Material( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + elevation: + Theme.of(context).appBarTheme.scrolledUnderElevation ?? 4, + shadowColor: Theme.of(context).appBarTheme.shadowColor, + child: ConstrainedBox( + constraints: isMobileMode + ? const BoxConstraints() + : const BoxConstraints(maxWidth: 960, maxHeight: 640), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Image.asset( + 'assets/login_wallpaper.png', + fit: BoxFit.cover, + ), + ), + Container( + width: 1, + color: Theme.of(context).dividerTheme.color, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: scaffold, + ), + ), + ], + ), + ), + ), + ), ), ), + const _PrivacyButtons(mainAxisAlignment: MainAxisAlignment.end), + ], + ), + ); + } +} + +class _PrivacyButtons extends StatelessWidget { + final MainAxisAlignment mainAxisAlignment; + const _PrivacyButtons({required this.mainAxisAlignment}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 64, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: mainAxisAlignment, + children: [ + TextButton( + onPressed: () => PlatformInfos.showDialog(context), + child: Text(L10n.of(context)!.about), + ), + TextButton( + onPressed: () => launchUrlString(AppConfig.privacyUrl), + child: Text(L10n.of(context)!.privacy), + ), + ], ), ), );