diff --git a/integration_test/extensions/default_flows.dart b/integration_test/extensions/default_flows.dart index 660996900..b04acaafb 100644 --- a/integration_test/extensions/default_flows.dart +++ b/integration_test/extensions/default_flows.dart @@ -1,7 +1,7 @@ import 'dart:developer'; import 'package:fluffychat/pages/chat_list/chat_list_body.dart'; -import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; +import 'package:fluffychat/pages/intro/intro_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -132,7 +132,7 @@ extension DefaultFlowExtensions on WidgetTester { final tester = this; await tester.pumpAndSettle(); - final homeserverPickerFinder = find.byType(HomeserverPicker); + final homeserverPickerFinder = find.byType(IntroPage); final chatListFinder = find.byType(ChatListViewBody); final end = DateTime.now().add(timeout); diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 9871a013f..42bde4d9a 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; +import 'package:fluffychat/pages/intro/intro_page.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -16,7 +18,6 @@ import 'package:fluffychat/pages/chat_members/chat_members.dart'; import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart'; import 'package:fluffychat/pages/chat_search/chat_search_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'; import 'package:fluffychat/pages/login/login.dart'; import 'package:fluffychat/pages/new_group/new_group.dart'; @@ -71,12 +72,12 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - const HomeserverPicker(addMultiAccount: false), + const IntroPage(addMultiAccount: false), ), redirect: loggedInRedirect, routes: [ GoRoute( - path: 'login', + path: 'login_mxid', pageBuilder: (context, state) => defaultPageBuilder( context, state, @@ -84,6 +85,24 @@ abstract class AppRoutes { ), redirect: loggedInRedirect, ), + GoRoute( + path: 'login', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const HomeserverPickerPage(type: HomeserverPickerType.login), + ), + redirect: loggedInRedirect, + ), + GoRoute( + path: 'register', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const HomeserverPickerPage(type: HomeserverPickerType.register), + ), + redirect: loggedInRedirect, + ), ], ), GoRoute( @@ -259,7 +278,7 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - const HomeserverPicker(addMultiAccount: true), + const IntroPage(addMultiAccount: true), ), routes: [ GoRoute( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index da34671cc..64d03b846 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3374,5 +3374,7 @@ "moreEvents": "More events", "@moreEvents": {}, "declineInvitation": "Decline invitation", - "@declineInvitation": {} + "@declineInvitation": {}, + "loginWithExistingAccount": "Login with existing account", + "createNewAccount": "Create new account" } diff --git a/lib/pages/homeserver_picker/homeserver_picker.dart b/lib/pages/homeserver_picker/homeserver_picker.dart index b17a3bc06..b9a076b5b 100644 --- a/lib/pages/homeserver_picker/homeserver_picker.dart +++ b/lib/pages/homeserver_picker/homeserver_picker.dart @@ -1,238 +1,181 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; -import 'package:universal_html/html.dart' as html; -import 'package:url_launcher/url_launcher_string.dart'; - import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view.dart'; -import 'package:fluffychat/utils/file_selector.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../utils/localized_exception_extension.dart'; - -import 'package:fluffychat/utils/tor_stub.dart' - if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart'; +import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; -class HomeserverPicker extends StatefulWidget { - final bool addMultiAccount; - const HomeserverPicker({required this.addMultiAccount, super.key}); +class HomeserverPickerPage extends StatelessWidget { + final HomeserverPickerType type; + const HomeserverPickerPage({required this.type, super.key}); @override - HomeserverPickerController createState() => HomeserverPickerController(); -} - -class HomeserverPickerController extends State { - bool isLoading = false; - - final TextEditingController homeserverController = TextEditingController( - text: AppConfig.defaultHomeserver, - ); - - String? error; - - bool isTorBrowser = false; - - Future _checkTorBrowser() async { - if (!kIsWeb) return; - - final isTor = await TorBrowserDetector.isTorBrowser; - isTorBrowser = isTor; - } - - /// 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({bool legacyPasswordLogin = false}) async { - final homeserverInput = - homeserverController.text.trim().toLowerCase().replaceAll(' ', '-'); - - if (homeserverInput.isEmpty) { - final client = await Matrix.of(context).getLoginClient(); - setState(() { - error = loginFlows = null; - isLoading = false; - client.homeserver = null; - }); - return; - } - setState(() { - error = loginFlows = null; - isLoading = true; - }); - - final l10n = L10n.of(context); - - try { - var homeserver = Uri.parse(homeserverInput); - if (homeserver.scheme.isEmpty) { - homeserver = Uri.https(homeserverInput, ''); - } - final client = await Matrix.of(context).getLoginClient(); - final (_, _, loginFlows) = await client.checkHomeserver(homeserver); - this.loginFlows = loginFlows; - if (supportsSso && !legacyPasswordLogin) { - if (!PlatformInfos.isMobile) { - final consent = await showOkCancelAlertDialog( - context: context, - title: l10n.appWantsToUseForLogin(homeserverInput), - message: l10n.appWantsToUseForLoginDescription, - okLabel: l10n.continueText, - ); - if (consent != OkCancelResult.ok) return; - } - return ssoLoginAction(); - } - context.push( - '${GoRouter.of(context).routeInformationProvider.value.uri.path}/login', - extra: client, - ); - } catch (e) { - setState( - () => error = (e).toLocalizedString( - context, - ExceptionContext.checkHomeserver, - ), - ); - } finally { - if (mounted) { - setState(() => isLoading = false); - } - } - } - - List? loginFlows; - - bool _supportsFlow(String flowType) => - 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'); - - void ssoLoginAction() async { - final redirectUrl = kIsWeb - ? Uri.parse(html.window.location.href) - .resolveUri( - Uri(pathSegments: ['auth.html']), - ) - .toString() - : isDefaultPlatform - ? '${AppConfig.appOpenUrlScheme.toLowerCase()}://login' - : 'http://localhost:3001//login'; - final client = await Matrix.of(context).getLoginClient(); - final url = client.homeserver!.replace( - path: '/_matrix/client/v3/login/sso/redirect', - queryParameters: {'redirectUrl': redirectUrl}, - ); - - final urlScheme = isDefaultPlatform - ? Uri.parse(redirectUrl).scheme - : "http://localhost:3001"; - final result = await FlutterWebAuth2.authenticate( - url: url.toString(), - callbackUrlScheme: urlScheme, - options: const FlutterWebAuth2Options(), + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return HomeserverPickerViewModel( + type: type, + builder: (context, state) { + final selectedHomserver = state.selectedHomeserver; + final publicHomeservers = state.publicHomeservers; + return LoginScaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + switch (type) { + HomeserverPickerType.login => + L10n.of(context).loginWithExistingAccount, + HomeserverPickerType.register => + L10n.of(context).createNewAccount, + }, + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + readOnly: state.isLoading, + controller: state.filterTextController, + decoration: InputDecoration( + filled: true, + errorText: state.error, + fillColor: theme.colorScheme.surfaceContainer, + prefixIcon: const Icon(Icons.search_outlined), + hintText: 'Choose a server... Any server!', + suffixIcon: IconButton( + icon: const Icon(Icons.help_outline_outlined), + onPressed: () {}, + ), + ), + ), + ), + ), + ), + body: publicHomeservers == null + ? state.error != null + ? Center( + child: TextButton( + onPressed: state.fetchHomeservers, + child: Text(L10n.of(context).tryAgain), + ), + ) + : const Center( + child: CircularProgressIndicator.adaptive(), + ) + : RadioGroup( + groupValue: state.selectedHomeserver, + onChanged: state.selectHomeserver, + child: ListView.builder( + itemCount: publicHomeservers.length, + itemBuilder: (context, i) { + final server = publicHomeservers[i]; + return RadioListTile.adaptive( + enabled: !state.isLoading, + value: server, + radioScaleFactor: 2, + secondary: IconButton( + icon: const Icon(Icons.info_outlined), + onPressed: () {}, + ), + title: Text(server.name ?? 'Unknown'), + subtitle: Column( + spacing: 4.0, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (server.languages?.isNotEmpty == true) + Row( + spacing: 4.0, + children: server.languages! + .map( + (language) => Material( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + color: + theme.colorScheme.tertiaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6.0, + vertical: 3.0, + ), + child: Text( + language, + style: TextStyle( + fontSize: 10, + color: theme.colorScheme + .onTertiaryContainer, + ), + ), + ), + ), + ) + .toList(), + ), + if (server.features?.isNotEmpty == true) + Row( + spacing: 4.0, + children: server.features! + .map( + (feature) => Material( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + color: theme + .colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6.0, + vertical: 3.0, + ), + child: Text( + feature, + style: TextStyle( + fontSize: 10, + color: theme.colorScheme + .onSecondaryContainer, + ), + ), + ), + ), + ) + .toList(), + ), + Text( + server.description ?? 'A general homeserver', + ), + ], + ), + ); + }, + ), + ), + bottomNavigationBar: AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: selectedHomserver == null + ? const SizedBox.shrink() + : Material( + elevation: 8, + shadowColor: theme.appBarTheme.shadowColor, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton( + onPressed: state.isLoading + ? null + : () => state + .checkHomeserverAction(selectedHomserver.name!), + child: state.isLoading + ? const CircularProgressIndicator.adaptive() + : Text(L10n.of(context).continueText), + ), + ), + ), + ), + ); + }, ); - final token = Uri.parse(result).queryParameters['loginToken']; - if (token?.isEmpty ?? false) return; - - setState(() { - error = null; - isLoading = true; - }); - try { - await client.login( - LoginType.mLoginToken, - token: token, - initialDeviceDisplayName: PlatformInfos.clientName, - ); - } catch (e) { - setState(() { - error = e.toLocalizedString(context); - }); - } finally { - if (mounted) { - setState(() { - isLoading = false; - }); - } - } - } - - @override - void initState() { - _checkTorBrowser(); - super.initState(); - } - - @override - Widget build(BuildContext context) => HomeserverPickerView(this); - - Future restoreBackup() async { - final picked = await selectFiles(context); - final file = picked.firstOrNull; - if (file == null) return; - setState(() { - error = null; - isLoading = true; - }); - try { - final client = await Matrix.of(context).getLoginClient(); - await client.importDump(String.fromCharCodes(await file.readAsBytes())); - Matrix.of(context).initMatrix(); - } catch (e) { - setState(() { - error = e.toLocalizedString(context); - }); - } finally { - if (mounted) { - setState(() { - isLoading = false; - }); - } - } - } - - void onMoreAction(MoreLoginActions action) { - switch (action) { - case MoreLoginActions.importBackup: - restoreBackup(); - case MoreLoginActions.privacy: - launchUrlString(AppConfig.privacyUrl); - case MoreLoginActions.about: - PlatformInfos.showDialog(context); - } } } -enum MoreLoginActions { importBackup, privacy, about } - -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'], - ); -} +enum HomeserverPickerType { login, register } diff --git a/lib/pages/homeserver_picker/homeserver_picker_view.dart b/lib/pages/homeserver_picker/homeserver_picker_view.dart deleted file mode 100644 index 4161f1f9a..000000000 --- a/lib/pages/homeserver_picker/homeserver_picker_view.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; -import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../config/themes.dart'; -import 'homeserver_picker.dart'; - -class HomeserverPickerView extends StatelessWidget { - final HomeserverPickerController controller; - - const HomeserverPickerView( - this.controller, { - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return LoginScaffold( - enforceMobileMode: - Matrix.of(context).widget.clients.any((client) => client.isLogged()), - appBar: AppBar( - centerTitle: true, - title: Text( - controller.widget.addMultiAccount - ? L10n.of(context).addAccount - : L10n.of(context).login, - ), - actions: [ - PopupMenuButton( - useRootNavigator: true, - onSelected: controller.onMoreAction, - itemBuilder: (_) => [ - PopupMenuItem( - value: MoreLoginActions.importBackup, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.import_export_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).hydrate), - ], - ), - ), - PopupMenuItem( - value: MoreLoginActions.privacy, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.privacy_tip_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).privacy), - ], - ), - ), - PopupMenuItem( - value: MoreLoginActions.about, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.info_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).about), - ], - ), - ), - ], - ), - ], - ), - body: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - children: [ - // 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 - AnimatedContainer( - height: controller.isTorBrowser ? 64 : 0, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: Material( - clipBehavior: Clip.hardEdge, - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(8), - ), - color: theme.colorScheme.surface, - child: ListTile( - leading: const Icon(Icons.vpn_key), - title: Text(L10n.of(context).hydrateTor), - subtitle: Text(L10n.of(context).hydrateTorLong), - trailing: const Icon(Icons.chevron_right_outlined), - onTap: controller.restoreBackup, - ), - ), - ), - Container( - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Hero( - tag: 'info-logo', - child: Image.asset( - './assets/banner_transparent.png', - fit: BoxFit.fitWidth, - ), - ), - ), - const SizedBox(height: 32), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: SelectableLinkify( - text: L10n.of(context).appIntroduction, - textScaleFactor: - MediaQuery.textScalerOf(context).scale(1), - textAlign: TextAlign.center, - linkStyle: TextStyle( - color: theme.colorScheme.secondary, - decorationColor: theme.colorScheme.secondary, - ), - onOpen: (link) => launchUrlString(link.url), - ), - ), - const Spacer(), - Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - onSubmitted: (_) => - controller.checkHomeserverAction(), - controller: controller.homeserverController, - autocorrect: false, - keyboardType: TextInputType.url, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search_outlined), - filled: false, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - ), - hintText: AppConfig.defaultHomeserver, - hintStyle: TextStyle( - color: theme.colorScheme.surfaceTint, - ), - labelText: 'Sign in with:', - errorText: controller.error, - errorMaxLines: 4, - suffixIcon: IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog.adaptive( - title: Text( - L10n.of(context).whatIsAHomeserver, - ), - content: Linkify( - text: L10n.of(context) - .homeserverDescription, - textScaleFactor: - MediaQuery.textScalerOf(context) - .scale(1), - options: const LinkifyOptions( - humanize: false, - ), - linkStyle: TextStyle( - color: theme.colorScheme.primary, - decorationColor: - theme.colorScheme.primary, - ), - onOpen: (link) => - launchUrlString(link.url), - ), - actions: [ - AdaptiveDialogAction( - onPressed: () => launchUrl( - Uri.https('servers.joinmatrix.org'), - ), - child: Text( - L10n.of(context) - .discoverHomeservers, - ), - ), - AdaptiveDialogAction( - onPressed: Navigator.of(context).pop, - child: Text(L10n.of(context).close), - ), - ], - ), - ); - }, - icon: const Icon(Icons.info_outlined), - ), - ), - ), - const SizedBox(height: 32), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - ), - onPressed: controller.isLoading - ? null - : controller.checkHomeserverAction, - child: controller.isLoading - ? const LinearProgressIndicator() - : Text(L10n.of(context).continueText), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: theme.colorScheme.secondary, - textStyle: theme.textTheme.labelMedium, - ), - onPressed: controller.isLoading - ? null - : () => controller.checkHomeserverAction( - legacyPasswordLogin: true, - ), - child: Text(L10n.of(context).loginWithMatrixId), - ), - ], - ), - ), - ], - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/pages/homeserver_picker/homeserver_picker_view_model.dart b/lib/pages/homeserver_picker/homeserver_picker_view_model.dart new file mode 100644 index 000000000..6ed86438a --- /dev/null +++ b/lib/pages/homeserver_picker/homeserver_picker_view_model.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; +import 'package:fluffychat/pages/homeserver_picker/public_homeserver_data.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:universal_html/html.dart' as html; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../../utils/localized_exception_extension.dart'; + +class HomeserverPickerViewModel extends StatefulWidget { + final HomeserverPickerType type; + final Widget Function(BuildContext, HomeserverPickerViewModelState) builder; + const HomeserverPickerViewModel({ + required this.type, + required this.builder, + super.key, + }); + + @override + HomeserverPickerViewModelState createState() => + HomeserverPickerViewModelState(); +} + +class HomeserverPickerViewModelState extends State { + bool isLoading = false; + + final TextEditingController filterTextController = TextEditingController(); + + String? error; + + List? publicHomeservers; + PublicHomeserverData? selectedHomeserver; + + @override + void initState() { + super.initState(); + fetchHomeservers(); + } + + void selectHomeserver(PublicHomeserverData? server) { + setState(() { + selectedHomeserver = server; + }); + } + + void fetchHomeservers() async { + setState(() { + error = null; + isLoading = true; + }); + try { + final client = await Matrix.of(context).getLoginClient(); + final response = await client.httpClient.get(AppConfig.homeserverList); + final json = jsonDecode(response.body) as Map; + final homeserverJsonList = json['public_servers'] as List; + + final publicHomeservers = homeserverJsonList + .map((json) => PublicHomeserverData.fromJson(json)) + .toList(); + + if (widget.type == HomeserverPickerType.register) { + publicHomeservers.removeWhere((server) { + return server.regMethod == null; + }); + } + + final defaultServer = publicHomeservers.singleWhereOrNull( + (server) => server.name == AppConfig.defaultHomeserver, + ) ?? + PublicHomeserverData(name: AppConfig.defaultHomeserver); + + publicHomeservers.insert( + 0, + defaultServer, + ); + + setState(() { + selectedHomeserver = defaultServer; + this.publicHomeservers = publicHomeservers; + isLoading = false; + }); + } catch (e, s) { + Logs().w('Unable to fetch public homeservers...', e, s); + setState(() { + isLoading = false; + error = e.toLocalizedString(context); + publicHomeservers = [ + PublicHomeserverData(name: AppConfig.defaultHomeserver), + ]; + }); + } + } + + /// 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( + String homeserverInput, { + bool legacyPasswordLogin = false, + }) async { + if (homeserverInput.isEmpty) { + final client = await Matrix.of(context).getLoginClient(); + setState(() { + error = loginFlows = null; + isLoading = false; + client.homeserver = null; + }); + return; + } + setState(() { + error = loginFlows = null; + isLoading = true; + }); + + final l10n = L10n.of(context); + + try { + var homeserver = Uri.parse(homeserverInput); + if (homeserver.scheme.isEmpty) { + homeserver = Uri.https(homeserverInput, ''); + } + final client = await Matrix.of(context).getLoginClient(); + final (_, _, loginFlows) = await client.checkHomeserver(homeserver); + this.loginFlows = loginFlows; + if (supportsSso && !legacyPasswordLogin) { + if (!PlatformInfos.isMobile) { + final consent = await showOkCancelAlertDialog( + context: context, + title: l10n.appWantsToUseForLogin(homeserverInput), + message: l10n.appWantsToUseForLoginDescription, + okLabel: l10n.continueText, + ); + if (consent != OkCancelResult.ok) return; + } + return ssoLoginAction(); + } + context.push( + '${GoRouter.of(context).routeInformationProvider.value.uri.path}/login', + extra: client, + ); + } catch (e) { + setState( + () => error = (e).toLocalizedString( + context, + ExceptionContext.checkHomeserver, + ), + ); + } finally { + if (mounted) { + setState(() => isLoading = false); + } + } + } + + List? loginFlows; + + bool _supportsFlow(String flowType) => + 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'); + + void ssoLoginAction() async { + final redirectUrl = kIsWeb + ? Uri.parse(html.window.location.href) + .resolveUri( + Uri(pathSegments: ['auth.html']), + ) + .toString() + : isDefaultPlatform + ? '${AppConfig.appOpenUrlScheme.toLowerCase()}://login' + : 'http://localhost:3001//login'; + final client = await Matrix.of(context).getLoginClient(); + final url = client.homeserver!.replace( + path: '/_matrix/client/v3/login/sso/redirect', + queryParameters: {'redirectUrl': redirectUrl}, + ); + + final urlScheme = isDefaultPlatform + ? Uri.parse(redirectUrl).scheme + : "http://localhost:3001"; + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: urlScheme, + options: const FlutterWebAuth2Options(), + ); + final token = Uri.parse(result).queryParameters['loginToken']; + if (token?.isEmpty ?? false) return; + + setState(() { + error = null; + isLoading = true; + }); + try { + await client.login( + LoginType.mLoginToken, + token: token, + initialDeviceDisplayName: PlatformInfos.clientName, + ); + } catch (e) { + setState(() { + error = e.toLocalizedString(context); + }); + } finally { + if (mounted) { + setState(() { + isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) => widget.builder(context, this); +} diff --git a/lib/pages/homeserver_picker/public_homeserver_data.dart b/lib/pages/homeserver_picker/public_homeserver_data.dart new file mode 100644 index 000000000..b8046b821 --- /dev/null +++ b/lib/pages/homeserver_picker/public_homeserver_data.dart @@ -0,0 +1,79 @@ +class PublicHomeserverData { + final String? name; + final String? clientDomain; + final String? homepage; + final String? isp; + final String? staffJur; + final String? rules; + final String? privacy; + final bool? usingVanillaReg; + final String? description; + final String? regMethod; + final String? regLink; + final String? software; + final String? version; + final bool? captcha; + final bool? email; + final List? languages; + final List? features; + final int? onlineStatus; + final String? serverDomain; + final int? verStatus; + final int? roomDirectory; + final bool? slidingSync; + final bool? ipv6; + + PublicHomeserverData({ + this.name, + this.clientDomain, + this.homepage, + this.isp, + this.staffJur, + this.rules, + this.privacy, + this.usingVanillaReg, + this.description, + this.regMethod, + this.regLink, + this.software, + this.version, + this.captcha, + this.email, + this.languages, + this.features, + this.onlineStatus, + this.serverDomain, + this.verStatus, + this.roomDirectory, + this.slidingSync, + this.ipv6, + }); + + factory PublicHomeserverData.fromJson(Map json) { + return PublicHomeserverData( + name: json['name'], + clientDomain: json['client_domain'], + homepage: json['homepage'], + isp: json['isp'], + staffJur: json['staff_jur'], + rules: json['rules'], + privacy: json['privacy'], + usingVanillaReg: json['using_vanilla_reg'], + description: json['description'], + regMethod: json['reg_method'], + regLink: json['reg_link'], + software: json['software'], + version: json['version'], + captcha: json['captcha'], + email: json['email'], + languages: List.from(json['languages'] ?? []), + features: List.from(json['features'] ?? []), + onlineStatus: json['online_status'], + serverDomain: json['server_domain'], + verStatus: json['ver_status'], + roomDirectory: json['room_directory'], + slidingSync: json['sliding_sync'], + ipv6: json['ipv6'], + ); + } +} diff --git a/lib/pages/intro/intro_page.dart b/lib/pages/intro/intro_page.dart new file mode 100644 index 000000000..b9a8559bd --- /dev/null +++ b/lib/pages/intro/intro_page.dart @@ -0,0 +1,180 @@ +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/intro/intro_page_view_model.dart'; +import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class IntroPage extends StatelessWidget { + final bool addMultiAccount; + const IntroPage({this.addMultiAccount = false, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return IntroPageViewModel( + builder: (context, state) { + return LoginScaffold( + enforceMobileMode: Matrix.of(context) + .widget + .clients + .any((client) => client.isLogged()), + appBar: AppBar( + centerTitle: true, + title: Text( + addMultiAccount + ? L10n.of(context).addAccount + : L10n.of(context).login, + ), + actions: [ + PopupMenuButton( + useRootNavigator: true, + onSelected: state.onMoreAction, + itemBuilder: (_) => [ + PopupMenuItem( + value: MoreLoginActions.loginWithMxid, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.login_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).loginWithMatrixId), + ], + ), + ), + PopupMenuItem( + value: MoreLoginActions.importBackup, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.import_export_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).hydrate), + ], + ), + ), + PopupMenuItem( + value: MoreLoginActions.privacy, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.privacy_tip_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).privacy), + ], + ), + ), + PopupMenuItem( + value: MoreLoginActions.about, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.info_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).about), + ], + ), + ), + ], + ), + ], + ), + body: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + children: [ + // 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 + AnimatedContainer( + height: state.isTorBrowser ? 64 : 0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: Material( + clipBehavior: Clip.hardEdge, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(8), + ), + color: theme.colorScheme.surface, + child: ListTile( + leading: const Icon(Icons.vpn_key), + title: Text(L10n.of(context).hydrateTor), + subtitle: Text(L10n.of(context).hydrateTorLong), + trailing: + const Icon(Icons.chevron_right_outlined), + onTap: () {}, + ), + ), + ), + Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Hero( + tag: 'info-logo', + child: Image.asset( + './assets/banner_transparent.png', + fit: BoxFit.fitWidth, + ), + ), + ), + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: SelectableLinkify( + text: L10n.of(context).appIntroduction, + textScaleFactor: + MediaQuery.textScalerOf(context).scale(1), + textAlign: TextAlign.center, + linkStyle: TextStyle( + color: theme.colorScheme.secondary, + decorationColor: theme.colorScheme.secondary, + ), + onOpen: (link) => launchUrlString(link.url), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + onPressed: state.createNewAccount, + child: Text(L10n.of(context).createNewAccount), + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom(), + onPressed: state.loginToExistingAccount, + child: Text( + L10n.of(context).loginWithExistingAccount, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/pages/intro/intro_page_view_model.dart b/lib/pages/intro/intro_page_view_model.dart new file mode 100644 index 000000000..11114d58b --- /dev/null +++ b/lib/pages/intro/intro_page_view_model.dart @@ -0,0 +1,89 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; + +import 'package:fluffychat/utils/tor_stub.dart' + if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class IntroPageViewModel extends StatefulWidget { + final Widget Function(BuildContext, IntroPageViewModelState) builder; + const IntroPageViewModel({required this.builder, super.key}); + + @override + State createState() => IntroPageViewModelState(); +} + +class IntroPageViewModelState extends State { + bool isTorBrowser = false; + bool isLoading = false; + String? error; + + @override + void initState() { + _checkTorBrowser(); + super.initState(); + } + + Future _checkTorBrowser() async { + if (!kIsWeb) return; + + final isTor = await TorBrowserDetector.isTorBrowser; + isTorBrowser = isTor; + } + + Future restoreBackup() async { + final picked = await selectFiles(context); + final file = picked.firstOrNull; + if (file == null) return; + setState(() { + error = null; + isLoading = true; + }); + try { + final client = await Matrix.of(context).getLoginClient(); + await client.importDump(String.fromCharCodes(await file.readAsBytes())); + Matrix.of(context).initMatrix(); + } catch (e) { + setState(() { + error = e.toLocalizedString(context); + }); + } finally { + if (mounted) { + setState(() { + isLoading = false; + }); + } + } + } + + void onMoreAction(MoreLoginActions action) async { + switch (action) { + case MoreLoginActions.importBackup: + restoreBackup(); + case MoreLoginActions.privacy: + launchUrlString(AppConfig.privacyUrl); + case MoreLoginActions.about: + PlatformInfos.showDialog(context); + case MoreLoginActions.loginWithMxid: + context.go( + "/home/login_mxid", + extra: await Matrix.of(context).getLoginClient(), + ); + } + } + + void createNewAccount() => context.go("/home/register"); + void loginToExistingAccount() => context.go("/home/login"); + void loginWithMxidExistingAccount() => context.go("/home/login_mxid"); + + @override + Widget build(BuildContext context) => widget.builder(context, this); +} + +enum MoreLoginActions { loginWithMxid, importBackup, privacy, about } diff --git a/lib/widgets/layouts/login_scaffold.dart b/lib/widgets/layouts/login_scaffold.dart index 9b2bc944a..a3b4cc3d8 100644 --- a/lib/widgets/layouts/login_scaffold.dart +++ b/lib/widgets/layouts/login_scaffold.dart @@ -10,12 +10,14 @@ import 'package:fluffychat/utils/platform_infos.dart'; class LoginScaffold extends StatelessWidget { final Widget body; final AppBar? appBar; + final Widget? bottomNavigationBar; final bool enforceMobileMode; const LoginScaffold({ super.key, required this.body, this.appBar, + this.bottomNavigationBar, this.enforceMobileMode = false, }); @@ -30,6 +32,7 @@ class LoginScaffold extends StatelessWidget { key: const Key('LoginScaffold'), appBar: appBar, body: SafeArea(child: body), + bottomNavigationBar: bottomNavigationBar, ); } return Container( @@ -64,6 +67,7 @@ class LoginScaffold extends StatelessWidget { key: const Key('LoginScaffold'), appBar: appBar, body: SafeArea(child: body), + bottomNavigationBar: bottomNavigationBar, ), ), ),