diff --git a/integration_test/extensions/default_flows.dart b/integration_test/extensions/default_flows.dart index 660996900..051d14b89 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); @@ -154,16 +154,10 @@ extension DefaultFlowExtensions on WidgetTester { chatListFinder.evaluate().isEmpty); if (homeserverPickerFinder.evaluate().isNotEmpty) { - log( - 'Found HomeserverPicker, performing login.', - name: 'Test Runner', - ); + log('Found HomeserverPicker, performing login.', name: 'Test Runner'); await tester.login(); } else { - log( - 'Found ChatListViewBody, skipping login.', - name: 'Test Runner', - ); + log('Found ChatListViewBody, skipping login.', name: 'Test Runner'); } await tester.acceptPushWarning(); diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 6aa4c9c55..83431003f 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -17,7 +17,7 @@ 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/intro/intro_page.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'; @@ -32,6 +32,7 @@ import 'package:fluffychat/pages/settings_notifications/settings_notifications.d import 'package:fluffychat/pages/settings_password/settings_password.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; +import 'package:fluffychat/pages/sign_in/sign_in_page.dart'; import 'package:fluffychat/widgets/config_viewer.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; @@ -66,13 +67,22 @@ abstract class AppRoutes { ), GoRoute( path: '/home', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const HomeserverPicker(addMultiAccount: false), - ), + pageBuilder: (context, state) => + defaultPageBuilder(context, state, const IntroPage()), redirect: loggedInRedirect, routes: [ + GoRoute( + path: 'sign_in', + pageBuilder: (context, state) => + defaultPageBuilder(context, state, SignInPage(signUp: false)), + redirect: loggedInRedirect, + ), + GoRoute( + path: 'sign_up', + pageBuilder: (context, state) => + defaultPageBuilder(context, state, SignInPage(signUp: true)), + redirect: loggedInRedirect, + ), GoRoute( path: 'login', pageBuilder: (context, state) => defaultPageBuilder( @@ -252,12 +262,27 @@ abstract class AppRoutes { GoRoute( path: 'addaccount', redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - const HomeserverPicker(addMultiAccount: true), - ), + pageBuilder: (context, state) => + defaultPageBuilder(context, state, const IntroPage()), routes: [ + GoRoute( + path: 'sign_in', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + SignInPage(signUp: false), + ), + redirect: loggedOutRedirect, + ), + GoRoute( + path: 'sign_up', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + SignInPage(signUp: true), + ), + redirect: loggedOutRedirect, + ), GoRoute( path: 'login', pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ef23ca6d1..d90ff8b3d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3491,5 +3491,10 @@ "@advancedConfigs": {}, "advancedConfigurations": "Advanced configurations", "@advancedConfigurations": {}, - "signInWithLabel": "Sign in with:" + "signIn": "Sign in", + "createNewAccount": "Create new account", + "signUpGreeting": "FluffyChat is decentralized! Select a server where you want to create your account and let's go!", + "signInGreeting": "You already have an account in Matrix? Welcome back! Select your homeserver and sign in.", + "appIntro": "With FluffyChat you can chat with your friends. It's a secure decentralized [matrix] messenger! Learn more on https://matrix.org if you like or just sign up.", + "theProcessWasCanceled": "The process was canceled." } diff --git a/lib/pages/homeserver_picker/homeserver_picker.dart b/lib/pages/homeserver_picker/homeserver_picker.dart deleted file mode 100644 index 7f3f6cfa6..000000000 --- a/lib/pages/homeserver_picker/homeserver_picker.dart +++ /dev/null @@ -1,221 +0,0 @@ -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.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/setting_keys.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'; - -class HomeserverPicker extends StatefulWidget { - final bool addMultiAccount; - const HomeserverPicker({required this.addMultiAccount, super.key}); - - @override - HomeserverPickerController createState() => HomeserverPickerController(); -} - -class HomeserverPickerController extends State { - bool isLoading = false; - - final TextEditingController homeserverController = TextEditingController( - text: AppSettings.defaultHomeserver.value, - ); - - String? error; - - /// 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: FlutterWebAuth2Options(useWebview: PlatformInfos.isMobile), - ); - 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) => 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: - launchUrl(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'], - ); -} 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 a3452b076..000000000 --- a/lib/pages/homeserver_picker/homeserver_picker_view.dart +++ /dev/null @@ -1,227 +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/config/setting_keys.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 '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: .min, - children: [ - const Icon(Icons.import_export_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).hydrate), - ], - ), - ), - PopupMenuItem( - value: MoreLoginActions.privacy, - child: Row( - 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: .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: [ - 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: .min, - 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: AppSettings.defaultHomeserver.value, - hintStyle: TextStyle( - color: theme.colorScheme.surfaceTint, - ), - labelText: L10n.of(context).signInWithLabel, - 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/intro/flows/restore_backup_flow.dart b/lib/pages/intro/flows/restore_backup_flow.dart new file mode 100644 index 000000000..c8f18052d --- /dev/null +++ b/lib/pages/intro/flows/restore_backup_flow.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/utils/file_selector.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +Future restoreBackupFlow(BuildContext context) async { + final picked = await selectFiles(context); + final file = picked.firstOrNull; + if (file == null) return; + + if (!context.mounted) return; + await showFutureLoadingDialog( + context: context, + future: () async { + final client = await Matrix.of(context).getLoginClient(); + await client.importDump(String.fromCharCodes(await file.readAsBytes())); + Matrix.of(context).initMatrix(); + }, + ); +} diff --git a/lib/pages/intro/intro_page.dart b/lib/pages/intro/intro_page.dart new file mode 100644 index 000000000..d973ae14d --- /dev/null +++ b/lib/pages/intro/intro_page.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:go_router/go_router.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/pages/intro/flows/restore_backup_flow.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class IntroPage extends StatelessWidget { + const IntroPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final addMultiAccount = Matrix.of( + context, + ).widget.clients.any((client) => client.isLogged()); + + return LoginScaffold( + appBar: AppBar( + centerTitle: true, + title: Text( + addMultiAccount + ? L10n.of(context).addAccount + : L10n.of(context).login, + ), + actions: [ + PopupMenuButton( + useRootNavigator: true, + itemBuilder: (_) => [ + PopupMenuItem( + onTap: () => restoreBackupFlow(context), + child: Row( + mainAxisSize: .min, + children: [ + const Icon(Icons.import_export_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).hydrate), + ], + ), + ), + PopupMenuItem( + onTap: () => launchUrl(AppConfig.privacyUrl), + child: Row( + mainAxisSize: .min, + children: [ + const Icon(Icons.privacy_tip_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).privacy), + ], + ), + ), + PopupMenuItem( + value: () => PlatformInfos.showDialog(context), + child: Row( + 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: [ + 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).appIntro, + 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: .min, + crossAxisAlignment: .stretch, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + foregroundColor: theme.colorScheme.onSecondary, + ), + onPressed: () => context.go( + '${GoRouterState.of(context).uri.path}/sign_up', + ), + child: Text(L10n.of(context).createNewAccount), + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.go( + '${GoRouterState.of(context).uri.path}/sign_in', + ), + child: Text(L10n.of(context).signIn), + ), + TextButton( + onPressed: () async { + final client = await Matrix.of( + context, + ).getLoginClient(); + context.go( + '${GoRouterState.of(context).uri.path}/login', + extra: client, + ); + }, + child: Text(L10n.of(context).loginWithMatrixId), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/login/login_view.dart b/lib/pages/login/login_view.dart index 77f4530c1..ca13ac21b 100644 --- a/lib/pages/login/login_view.dart +++ b/lib/pages/login/login_view.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'login.dart'; class LoginView extends StatelessWidget { @@ -15,32 +14,18 @@ class LoginView extends StatelessWidget { final theme = Theme.of(context); final homeserver = controller.widget.client.homeserver - .toString() + ?.toString() .replaceFirst('https://', ''); - final title = L10n.of(context).logInTo(homeserver); - final titleParts = title.split(homeserver); + final title = homeserver == null + ? L10n.of(context).loginWithMatrixId + : L10n.of(context).logInTo(homeserver); return LoginScaffold( - enforceMobileMode: Matrix.of( - context, - ).widget.clients.any((client) => client.isLogged()), appBar: AppBar( leading: controller.loading ? null : const Center(child: BackButton()), automaticallyImplyLeading: !controller.loading, titleSpacing: !controller.loading ? 0 : null, - title: Text.rich( - TextSpan( - children: [ - TextSpan(text: titleParts.first), - TextSpan( - text: homeserver, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - TextSpan(text: titleParts.last), - ], - ), - style: const TextStyle(fontSize: 18), - ), + title: Text(title), ), body: Builder( builder: (context) { @@ -121,18 +106,19 @@ class LoginView extends StatelessWidget { ), ), const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: TextButton( - onPressed: controller.loading - ? () {} - : controller.passwordForgotten, - style: TextButton.styleFrom( - foregroundColor: theme.colorScheme.error, + if (homeserver != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: TextButton( + onPressed: controller.loading + ? () {} + : controller.passwordForgotten, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + child: Text(L10n.of(context).passwordForgotten), ), - child: Text(L10n.of(context).passwordForgotten), ), - ), const SizedBox(height: 16), ], ), diff --git a/lib/pages/sign_in/sign_in_page.dart b/lib/pages/sign_in/sign_in_page.dart new file mode 100644 index 000000000..8dd4390a2 --- /dev/null +++ b/lib/pages/sign_in/sign_in_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.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/l10n/l10n.dart'; +import 'package:fluffychat/pages/sign_in/view_model/flows/check_homeserver.dart'; +import 'package:fluffychat/pages/sign_in/view_model/model/public_homeserver_data.dart'; +import 'package:fluffychat/pages/sign_in/view_model/sign_in_view_model.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/widgets/layouts/login_scaffold.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/view_model_builder.dart'; + +class SignInPage extends StatelessWidget { + final bool signUp; + const SignInPage({required this.signUp, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ViewModelBuilder( + create: () => SignInViewModel(Matrix.of(context), signUp: signUp), + builder: (context, viewModel, _) { + final state = viewModel.value; + final publicHomeservers = state.filteredPublicHomeservers; + final selectedHomserver = state.selectedHomeserver; + return LoginScaffold( + appBar: AppBar( + backgroundColor: theme.colorScheme.surface, + surfaceTintColor: theme.colorScheme.surface, + scrolledUnderElevation: 0, + centerTitle: true, + title: Text( + signUp + ? L10n.of(context).createNewAccount + : L10n.of(context).login, + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(56 + 60), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .center, + spacing: 12, + children: [ + SelectableText( + signUp + ? L10n.of(context).signUpGreeting + : L10n.of(context).signInGreeting, + textAlign: .center, + ), + TextField( + readOnly: + state.publicHomeservers.connectionState == + ConnectionState.waiting, + controller: viewModel.filterTextController, + decoration: InputDecoration( + filled: true, + fillColor: theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(99), + ), + errorText: state.publicHomeservers.error + ?.toLocalizedString(context), + prefixIcon: const Icon(Icons.search_outlined), + hintText: 'Search or enter homeserver address', + ), + ), + ], + ), + ), + ), + ), + body: state.publicHomeservers.connectionState == ConnectionState.done + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Material( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + color: theme.colorScheme.surfaceContainerLow, + child: RadioGroup( + groupValue: state.selectedHomeserver, + onChanged: viewModel.selectHomeserver, + child: ListView.builder( + itemCount: publicHomeservers.length, + itemBuilder: (context, i) { + final server = publicHomeservers[i]; + return RadioListTile.adaptive( + value: server, + radioScaleFactor: 2, + secondary: IconButton( + icon: const Icon(Icons.link_outlined), + onPressed: () => launchUrlString( + server.homepage ?? 'https://${server.name}', + ), + ), + title: Row( + spacing: 4, + children: [ + Expanded(child: Text(server.name ?? 'Unknown')), + ...?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, + ), + ), + ), + ), + ), + ], + ), + subtitle: Column( + spacing: 4.0, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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 matrix homeserver', + ), + ], + ), + ); + }, + ), + ), + ), + ) + : Center(child: CircularProgressIndicator.adaptive()), + bottomNavigationBar: AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: + selectedHomserver == null || + !publicHomeservers.contains(selectedHomserver) + ? const SizedBox.shrink() + : Material( + elevation: 8, + shadowColor: theme.appBarTheme.shadowColor, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton( + onPressed: + state.loginLoading.connectionState == + ConnectionState.waiting + ? null + : () => connectToHomeserverFlow( + selectedHomserver, + context, + viewModel.setLoginLoading, + signUp, + ), + child: + state.loginLoading.connectionState == + ConnectionState.waiting + ? const CircularProgressIndicator.adaptive() + : Text(L10n.of(context).continueText), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/pages/sign_in/view_model/flows/check_homeserver.dart b/lib/pages/sign_in/view_model/flows/check_homeserver.dart new file mode 100644 index 000000000..895a168b3 --- /dev/null +++ b/lib/pages/sign_in/view_model/flows/check_homeserver.dart @@ -0,0 +1,72 @@ +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'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/sign_in/view_model/flows/sso_login.dart'; +import 'package:fluffychat/pages/sign_in/view_model/model/public_homeserver_data.dart'; +import 'package:fluffychat/utils/localized_exception_extension.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'; + +void connectToHomeserverFlow( + PublicHomeserverData homeserverData, + BuildContext context, + void Function(AsyncSnapshot) setState, + bool signUp, +) async { + setState(AsyncSnapshot.waiting()); + try { + final homeserverInput = homeserverData.name!; + var homeserver = Uri.parse(homeserverInput); + if (homeserver.scheme.isEmpty) { + homeserver = Uri.https(homeserverInput, ''); + } + final l10n = L10n.of(context); + final client = await Matrix.of(context).getLoginClient(); + final (_, _, loginFlows, _) = await client.checkHomeserver(homeserver); + + final supportsSso = loginFlows.any((flow) => flow.type == 'm.login.sso'); + + if (!supportsSso) { + final regLink = homeserverData.regLink; + if (signUp && regLink != null) { + await launchUrlString(regLink); + } + + final pathSegments = List.of( + GoRouter.of(context).routeInformationProvider.value.uri.pathSegments, + ); + pathSegments.removeLast(); + pathSegments.add('login'); + context.go('/${pathSegments.join('/')}', extra: client); + setState(AsyncSnapshot.withData(ConnectionState.done, true)); + return; + } + if (kIsWeb || PlatformInfos.isLinux) { + final consent = await showOkCancelAlertDialog( + context: context, + title: l10n.appWantsToUseForLogin(homeserverInput), + message: l10n.appWantsToUseForLoginDescription, + okLabel: l10n.continueText, + ); + if (consent != OkCancelResult.ok) return; + } + await ssoLoginFlow(client, context, signUp); + + setState(AsyncSnapshot.withData(ConnectionState.done, true)); + } catch (e, s) { + setState(AsyncSnapshot.withError(ConnectionState.done, e, s)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toLocalizedString(context, ExceptionContext.checkHomeserver), + ), + ), + ); + } +} diff --git a/lib/pages/sign_in/view_model/flows/sort_homeservers.dart b/lib/pages/sign_in/view_model/flows/sort_homeservers.dart new file mode 100644 index 000000000..7c54a01b8 --- /dev/null +++ b/lib/pages/sign_in/view_model/flows/sort_homeservers.dart @@ -0,0 +1,24 @@ +import 'package:fluffychat/pages/sign_in/view_model/model/public_homeserver_data.dart'; + +int sortHomeservers(PublicHomeserverData a, PublicHomeserverData b) { + return _calcHomeserverScore(b).compareTo(_calcHomeserverScore(a)); +} + +int _calcHomeserverScore(PublicHomeserverData homeserver) { + var score = 0; + if (homeserver.description?.isNotEmpty == true) score++; + if (homeserver.homepage?.isNotEmpty == true) score++; + score += (homeserver.languages?.length ?? 0); + score += (homeserver.features?.length ?? 0); + score += (homeserver.onlineStatus ?? 0); + if (homeserver.ipv6 == true) score++; + if (homeserver.isp?.isNotEmpty == true) score++; + if (homeserver.privacy?.isNotEmpty == true) score++; + if (homeserver.rules?.isNotEmpty == true) score++; + if (homeserver.version?.isNotEmpty == true) score++; + if (homeserver.usingVanillaReg == true) score--; + if (homeserver.regLink != null) score--; + if (homeserver.regMethod != 'SSO') score--; + if (homeserver.regMethod == 'In-house Element') score--; + return score; +} diff --git a/lib/pages/sign_in/view_model/flows/sso_login.dart b/lib/pages/sign_in/view_model/flows/sso_login.dart new file mode 100644 index 000000000..830693ef0 --- /dev/null +++ b/lib/pages/sign_in/view_model/flows/sso_login.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:matrix/matrix.dart'; +import 'package:universal_html/html.dart' as html; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; + +Future ssoLoginFlow( + Client client, + BuildContext context, + bool signUp, +) async { + final redirectUrl = kIsWeb + ? Uri.parse( + html.window.location.href, + ).resolveUri(Uri(pathSegments: ['auth.html'])).toString() + : (PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS) + ? '${AppConfig.appOpenUrlScheme.toLowerCase()}://login' + : 'http://localhost:3001//login'; + + final url = client.homeserver!.replace( + path: '/_matrix/client/v3/login/sso/redirect', + queryParameters: { + 'redirectUrl': redirectUrl, + 'action': signUp ? 'register' : 'login', + }, + ); + + final urlScheme = + (PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS) + ? Uri.parse(redirectUrl).scheme + : "http://localhost:3001"; + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: urlScheme, + options: FlutterWebAuth2Options(useWebview: PlatformInfos.isMobile), + ); + final token = Uri.parse(result).queryParameters['loginToken']; + if (token?.isEmpty ?? false) return; + + await client.login( + LoginType.mLoginToken, + token: token, + initialDeviceDisplayName: PlatformInfos.clientName, + ); +} diff --git a/lib/pages/sign_in/view_model/model/public_homeserver_data.dart b/lib/pages/sign_in/view_model/model/public_homeserver_data.dart new file mode 100644 index 000000000..b8046b821 --- /dev/null +++ b/lib/pages/sign_in/view_model/model/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/sign_in/view_model/sign_in_state.dart b/lib/pages/sign_in/view_model/sign_in_state.dart new file mode 100644 index 000000000..84213152e --- /dev/null +++ b/lib/pages/sign_in/view_model/sign_in_state.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pages/sign_in/view_model/model/public_homeserver_data.dart'; + +class SignInState { + final PublicHomeserverData? selectedHomeserver; + final AsyncSnapshot> publicHomeservers; + final List filteredPublicHomeservers; + final AsyncSnapshot loginLoading; + + const SignInState({ + this.selectedHomeserver, + this.publicHomeservers = const AsyncSnapshot.nothing(), + this.loginLoading = const AsyncSnapshot.nothing(), + this.filteredPublicHomeservers = const [], + }); + + SignInState copyWith({ + PublicHomeserverData? selectedHomeserver, + AsyncSnapshot>? publicHomeservers, + AsyncSnapshot? loginLoading, + List? filteredPublicHomeservers, + }) { + return SignInState( + selectedHomeserver: selectedHomeserver ?? this.selectedHomeserver, + publicHomeservers: publicHomeservers ?? this.publicHomeservers, + loginLoading: loginLoading ?? this.loginLoading, + filteredPublicHomeservers: + filteredPublicHomeservers ?? this.filteredPublicHomeservers, + ); + } +} diff --git a/lib/pages/sign_in/view_model/sign_in_view_model.dart b/lib/pages/sign_in/view_model/sign_in_view_model.dart new file mode 100644 index 000000000..55ff005c1 --- /dev/null +++ b/lib/pages/sign_in/view_model/sign_in_view_model.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; + +import 'package:collection/collection.dart'; +import 'package:matrix/matrix_api_lite/utils/logs.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/pages/sign_in/view_model/flows/sort_homeservers.dart'; +import 'package:fluffychat/pages/sign_in/view_model/model/public_homeserver_data.dart'; +import 'package:fluffychat/pages/sign_in/view_model/sign_in_state.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SignInViewModel extends ValueNotifier { + final MatrixState matrixService; + final bool signUp; + final TextEditingController filterTextController = TextEditingController(); + + SignInViewModel(this.matrixService, {required this.signUp}) + : super(SignInState()) { + refreshPublicHomeservers(); + filterTextController.addListener(_filterHomeservers); + } + + @override + void dispose() { + filterTextController.removeListener(_filterHomeservers); + super.dispose(); + } + + void _filterHomeservers() { + final filterText = filterTextController.text.trim().toLowerCase(); + final filteredPublicHomeservers = + value.publicHomeservers.data + ?.where( + (homeserver) => + homeserver.name?.toLowerCase().contains(filterText) ?? false, + ) + .toList() ?? + []; + final splitted = filterText.split('.'); + if (splitted.length >= 2 && !splitted.any((part) => part.isEmpty)) { + if (!filteredPublicHomeservers.any( + (homeserver) => homeserver.name == filterText, + )) { + filteredPublicHomeservers.add(PublicHomeserverData(name: filterText)); + } + } + value = value.copyWith( + filteredPublicHomeservers: filteredPublicHomeservers, + ); + } + + void refreshPublicHomeservers() async { + value = value.copyWith(publicHomeservers: AsyncSnapshot.waiting()); + final defaultHomeserverData = PublicHomeserverData( + name: AppSettings.defaultHomeserver.value, + ); + try { + final client = await matrixService.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 (signUp) { + publicHomeservers.removeWhere((server) { + return server.regMethod == null; + }); + } + + publicHomeservers.sort(sortHomeservers); + + final defaultServer = + publicHomeservers.singleWhereOrNull( + (server) => server.name == AppSettings.defaultHomeserver.value, + ) ?? + defaultHomeserverData; + + publicHomeservers.insert(0, defaultServer); + + value = value.copyWith( + selectedHomeserver: value.selectedHomeserver ?? publicHomeservers.first, + publicHomeservers: AsyncSnapshot.withData( + ConnectionState.done, + publicHomeservers, + ), + ); + } catch (e, s) { + Logs().w('Unable to fetch public homeservers...', e, s); + value = value.copyWith( + selectedHomeserver: defaultHomeserverData, + publicHomeservers: AsyncSnapshot.withData(ConnectionState.done, [ + defaultHomeserverData, + ]), + ); + } + _filterHomeservers(); + } + + void selectHomeserver(PublicHomeserverData? publicHomeserverData) { + value = value.copyWith(selectedHomeserver: publicHomeserverData); + } + + void setLoginLoading(AsyncSnapshot loginLoading) { + value = value.copyWith(loginLoading: loginLoading); + } +} diff --git a/lib/utils/localized_exception_extension.dart b/lib/utils/localized_exception_extension.dart index 44990b37f..0d8ff6599 100644 --- a/lib/utils/localized_exception_extension.dart +++ b/lib/utils/localized_exception_extension.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart'; import 'package:matrix/encryption.dart'; @@ -57,6 +58,15 @@ extension LocalizedExceptionExtension on Object { if (this is InvalidPassphraseException) { return L10n.of(context).wrongRecoveryKey; } + if (this is PlatformException) { + if ((this as PlatformException).code == 'CANCELED') { + return L10n.of(context).theProcessWasCanceled; + } + final message = (this as PlatformException).message; + if (message != null) { + return message; + } + } if (this is BadServerLoginTypesException) { final serverVersions = (this as BadServerLoginTypesException) .serverLoginTypes diff --git a/lib/widgets/layouts/login_scaffold.dart b/lib/widgets/layouts/login_scaffold.dart index ebf40384f..8066f1e05 100644 --- a/lib/widgets/layouts/login_scaffold.dart +++ b/lib/widgets/layouts/login_scaffold.dart @@ -12,83 +12,92 @@ import 'package:fluffychat/utils/platform_infos.dart'; class LoginScaffold extends StatelessWidget { final Widget body; final AppBar? appBar; - final bool enforceMobileMode; + final Widget? bottomNavigationBar; const LoginScaffold({ super.key, required this.body, this.appBar, - this.enforceMobileMode = false, + this.bottomNavigationBar, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isMobileMode = - enforceMobileMode || !FluffyThemes.isColumnMode(context); - if (isMobileMode) { - return Scaffold( - key: const Key('LoginScaffold'), - appBar: appBar, - body: SafeArea(child: body), - ); - } - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - theme.colorScheme.surfaceContainerLow, - theme.colorScheme.surfaceContainer, - theme.colorScheme.surfaceContainerHighest, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Stack( - children: [ - if (!MediaQuery.of(context).disableAnimations) - ParticleNetwork( - particleColor: theme.colorScheme.primary, - lineColor: theme.colorScheme.secondary, + return LayoutBuilder( + builder: (context, constraints) { + final isMobileMode = !FluffyThemes.isColumnModeByWidth( + constraints.maxWidth, + ); + if (isMobileMode) { + return Scaffold( + key: const Key('LoginScaffold'), + appBar: appBar, + body: SafeArea(child: body), + bottomNavigationBar: bottomNavigationBar, + ); + } + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.colorScheme.surfaceContainerLow, + theme.colorScheme.surfaceContainer, + theme.colorScheme.surfaceContainerHighest, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - Column( + ), + child: Stack( children: [ - const SizedBox(height: 16), - Expanded( - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Material( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - clipBehavior: Clip.hardEdge, - elevation: theme.appBarTheme.scrolledUnderElevation ?? 4, - shadowColor: theme.appBarTheme.shadowColor, - child: ConstrainedBox( - constraints: isMobileMode - ? const BoxConstraints() - : const BoxConstraints( - maxWidth: 480, - maxHeight: 640, - ), - child: Scaffold( - key: const Key('LoginScaffold'), - appBar: appBar, - body: SafeArea(child: body), + if (!MediaQuery.of(context).disableAnimations) + ParticleNetwork( + maxSpeed: 0.25, + particleColor: theme.colorScheme.primary, + lineColor: theme.colorScheme.secondary, + ), + Column( + children: [ + const SizedBox(height: 16), + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Material( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + clipBehavior: Clip.hardEdge, + elevation: + theme.appBarTheme.scrolledUnderElevation ?? 4, + shadowColor: theme.appBarTheme.shadowColor, + child: ConstrainedBox( + constraints: isMobileMode + ? const BoxConstraints() + : const BoxConstraints( + maxWidth: 480, + maxHeight: 640, + ), + child: Scaffold( + key: const Key('LoginScaffold'), + appBar: appBar, + body: SafeArea(child: body), + bottomNavigationBar: bottomNavigationBar, + ), + ), ), ), ), ), - ), + const _PrivacyButtons(mainAxisAlignment: .center), + ], ), - const _PrivacyButtons(mainAxisAlignment: .center), ], ), - ], - ), + ); + }, ); } } diff --git a/lib/widgets/view_model_builder.dart b/lib/widgets/view_model_builder.dart new file mode 100644 index 000000000..11c30b6e5 --- /dev/null +++ b/lib/widgets/view_model_builder.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class ViewModelBuilder extends StatefulWidget { + final T Function() create; + final Widget Function(BuildContext context, T viewModel, Widget? child) + builder; + final Widget? child; + const ViewModelBuilder({ + super.key, + required this.create, + required this.builder, + this.child, + }); + + @override + State> createState() => _ViewModelBuilderState(); +} + +class _ViewModelBuilderState + extends State> { + late final T _viewModel; + + @override + void initState() { + _viewModel = widget.create(); + super.initState(); + } + + @override + void dispose() { + _viewModel.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _viewModel, + builder: (context, value, child) => + widget.builder.call(context, _viewModel, child), + ); + } +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index f9cfc10c1..ce2bf67b4 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -26,7 +26,7 @@ PODS: - FlutterMacOS - flutter_vodozemac (0.0.1): - FlutterMacOS - - flutter_web_auth_2 (3.0.0): + - flutter_web_auth_2 (5.0.0): - FlutterMacOS - flutter_webrtc (1.2.0): - FlutterMacOS @@ -197,7 +197,7 @@ SPEC CHECKSUMS: flutter_new_badger: 6fe9bf7e42793a164032c21f164c0ad9985cd0f2 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 flutter_vodozemac: fd2ea9cb3e2a37beaac883a369811fbfe042fc53 - flutter_web_auth_2: 62b08da29f15a20fa63f144234622a1488d45b65 + flutter_web_auth_2: 7fe624ff12ddcb4c19e5dbcd56c9e9ea4899d967 flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e