feat: Implement new sign in flow

This commit is contained in:
Christian Kußowski
2026-02-01 17:57:03 +01:00
parent f7932639e2
commit 4d7f0295ca
19 changed files with 944 additions and 559 deletions

View File

@@ -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();

View File

@@ -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(

View File

@@ -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."
}

View File

@@ -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<HomeserverPicker> {
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<void> 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<LoginFlow>? 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<void> 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<String, dynamic> json) =>
IdentityProvider(
id: json['id'],
name: json['name'],
icon: json['icon'],
brand: json['brand'],
);
}

View File

@@ -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<MoreLoginActions>(
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),
),
],
),
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -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<void> 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();
},
);
}

View File

@@ -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),
),
],
),
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -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),
],
),

View File

@@ -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<PublicHomeserverData>(
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),
),
),
),
),
);
},
);
}
}

View File

@@ -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<bool>) 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),
),
),
);
}
}

View File

@@ -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;
}

View File

@@ -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<void> 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,
);
}

View File

@@ -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<String>? languages;
final List<String>? 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<String, dynamic> 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<String>.from(json['languages'] ?? []),
features: List<String>.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'],
);
}
}

View File

@@ -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<List<PublicHomeserverData>> publicHomeservers;
final List<PublicHomeserverData> filteredPublicHomeservers;
final AsyncSnapshot<bool> loginLoading;
const SignInState({
this.selectedHomeserver,
this.publicHomeservers = const AsyncSnapshot.nothing(),
this.loginLoading = const AsyncSnapshot.nothing(),
this.filteredPublicHomeservers = const [],
});
SignInState copyWith({
PublicHomeserverData? selectedHomeserver,
AsyncSnapshot<List<PublicHomeserverData>>? publicHomeservers,
AsyncSnapshot<bool>? loginLoading,
List<PublicHomeserverData>? filteredPublicHomeservers,
}) {
return SignInState(
selectedHomeserver: selectedHomeserver ?? this.selectedHomeserver,
publicHomeservers: publicHomeservers ?? this.publicHomeservers,
loginLoading: loginLoading ?? this.loginLoading,
filteredPublicHomeservers:
filteredPublicHomeservers ?? this.filteredPublicHomeservers,
);
}
}

View File

@@ -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<SignInState> {
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<String, dynamic>;
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<bool> loginLoading) {
value = value.copyWith(loginLoading: loginLoading);
}
}

View File

@@ -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

View File

@@ -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),
],
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class ViewModelBuilder<T extends ValueNotifier> 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<ViewModelBuilder<T>> createState() => _ViewModelBuilderState<T>();
}
class _ViewModelBuilderState<T extends ValueNotifier>
extends State<ViewModelBuilder<T>> {
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),
);
}
}

View File

@@ -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