Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Christian Kußowski
8594d2292b
feat: New login with homeserver picker 2025-09-07 12:04:22 +02:00
10 changed files with 783 additions and 484 deletions

View file

@ -1,7 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'package:fluffychat/pages/chat_list/chat_list_body.dart'; 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -132,7 +132,7 @@ extension DefaultFlowExtensions on WidgetTester {
final tester = this; final tester = this;
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final homeserverPickerFinder = find.byType(HomeserverPicker); final homeserverPickerFinder = find.byType(IntroPage);
final chatListFinder = find.byType(ChatListViewBody); final chatListFinder = find.byType(ChatListViewBody);
final end = DateTime.now().add(timeout); final end = DateTime.now().add(timeout);

View file

@ -1,5 +1,7 @@
import 'dart:async'; 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:flutter/material.dart';
import 'package:go_router/go_router.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_permissions_settings/chat_permissions_settings.dart';
import 'package:fluffychat/pages/chat_search/chat_search_page.dart'; import 'package:fluffychat/pages/chat_search/chat_search_page.dart';
import 'package:fluffychat/pages/device_settings/device_settings.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/invitation_selection/invitation_selection.dart';
import 'package:fluffychat/pages/login/login.dart'; import 'package:fluffychat/pages/login/login.dart';
import 'package:fluffychat/pages/new_group/new_group.dart'; import 'package:fluffychat/pages/new_group/new_group.dart';
@ -71,16 +72,34 @@ abstract class AppRoutes {
pageBuilder: (context, state) => defaultPageBuilder( pageBuilder: (context, state) => defaultPageBuilder(
context, context,
state, state,
const HomeserverPicker(addMultiAccount: false), const IntroPage(addMultiAccount: false),
), ),
redirect: loggedInRedirect, redirect: loggedInRedirect,
routes: [ routes: [
GoRoute(
path: 'login_mxid',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
Login(client: state.extra as Client),
),
redirect: loggedInRedirect,
),
GoRoute( GoRoute(
path: 'login', path: 'login',
pageBuilder: (context, state) => defaultPageBuilder( pageBuilder: (context, state) => defaultPageBuilder(
context, context,
state, state,
Login(client: state.extra as Client), const HomeserverPickerPage(type: HomeserverPickerType.login),
),
redirect: loggedInRedirect,
),
GoRoute(
path: 'register',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const HomeserverPickerPage(type: HomeserverPickerType.register),
), ),
redirect: loggedInRedirect, redirect: loggedInRedirect,
), ),
@ -259,7 +278,7 @@ abstract class AppRoutes {
pageBuilder: (context, state) => defaultPageBuilder( pageBuilder: (context, state) => defaultPageBuilder(
context, context,
state, state,
const HomeserverPicker(addMultiAccount: true), const IntroPage(addMultiAccount: true),
), ),
routes: [ routes: [
GoRoute( GoRoute(

View file

@ -3374,5 +3374,7 @@
"moreEvents": "More events", "moreEvents": "More events",
"@moreEvents": {}, "@moreEvents": {},
"declineInvitation": "Decline invitation", "declineInvitation": "Decline invitation",
"@declineInvitation": {} "@declineInvitation": {},
"loginWithExistingAccount": "Login with existing account",
"createNewAccount": "Create new account"
} }

View file

@ -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/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view.dart'; import 'package:fluffychat/pages/homeserver_picker/homeserver_picker_view_model.dart';
import 'package:fluffychat/utils/file_selector.dart'; import 'package:flutter/material.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/login_scaffold.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' class HomeserverPickerPage extends StatelessWidget {
if (dart.library.html) 'package:tor_detector_web/tor_detector_web.dart'; final HomeserverPickerType type;
const HomeserverPickerPage({required this.type, super.key});
class HomeserverPicker extends StatefulWidget {
final bool addMultiAccount;
const HomeserverPicker({required this.addMultiAccount, super.key});
@override @override
HomeserverPickerController createState() => HomeserverPickerController(); Widget build(BuildContext context) {
} final theme = Theme.of(context);
class HomeserverPickerController extends State<HomeserverPicker> { return HomeserverPickerViewModel(
bool isLoading = false; type: type,
builder: (context, state) {
final TextEditingController homeserverController = TextEditingController( final selectedHomserver = state.selectedHomeserver;
text: AppConfig.defaultHomeserver, final publicHomeservers = state.publicHomeservers;
); return LoginScaffold(
appBar: AppBar(
String? error; centerTitle: true,
title: Text(
bool isTorBrowser = false; switch (type) {
HomeserverPickerType.login =>
Future<void> _checkTorBrowser() async { L10n.of(context).loginWithExistingAccount,
if (!kIsWeb) return; HomeserverPickerType.register =>
L10n.of(context).createNewAccount,
final isTor = await TorBrowserDetector.isTorBrowser; },
isTorBrowser = isTor; ),
} bottom: PreferredSize(
preferredSize: const Size.fromHeight(56),
/// Starts an analysis of the given homeserver. It uses the current domain and child: Padding(
/// makes sure that it is prefixed with https. Then it searches for the padding: const EdgeInsets.all(16.0),
/// well-known information and forwards to the login page depending on the child: TextField(
/// login type. readOnly: state.isLoading,
Future<void> checkHomeserverAction({bool legacyPasswordLogin = false}) async { controller: state.filterTextController,
final homeserverInput = decoration: InputDecoration(
homeserverController.text.trim().toLowerCase().replaceAll(' ', '-'); filled: true,
errorText: state.error,
if (homeserverInput.isEmpty) { fillColor: theme.colorScheme.surfaceContainer,
final client = await Matrix.of(context).getLoginClient(); prefixIcon: const Icon(Icons.search_outlined),
setState(() { hintText: 'Choose a server... Any server!',
error = loginFlows = null; suffixIcon: IconButton(
isLoading = false; icon: const Icon(Icons.help_outline_outlined),
client.homeserver = null; onPressed: () {},
}); ),
return; ),
} ),
setState(() { ),
error = loginFlows = null; ),
isLoading = true; ),
}); body: publicHomeservers == null
? state.error != null
final l10n = L10n.of(context); ? Center(
child: TextButton(
try { onPressed: state.fetchHomeservers,
var homeserver = Uri.parse(homeserverInput); child: Text(L10n.of(context).tryAgain),
if (homeserver.scheme.isEmpty) { ),
homeserver = Uri.https(homeserverInput, ''); )
} : const Center(
final client = await Matrix.of(context).getLoginClient(); child: CircularProgressIndicator.adaptive(),
final (_, _, loginFlows) = await client.checkHomeserver(homeserver); )
this.loginFlows = loginFlows; : RadioGroup(
if (supportsSso && !legacyPasswordLogin) { groupValue: state.selectedHomeserver,
if (!PlatformInfos.isMobile) { onChanged: state.selectHomeserver,
final consent = await showOkCancelAlertDialog( child: ListView.builder(
context: context, itemCount: publicHomeservers.length,
title: l10n.appWantsToUseForLogin(homeserverInput), itemBuilder: (context, i) {
message: l10n.appWantsToUseForLoginDescription, final server = publicHomeservers[i];
okLabel: l10n.continueText, return RadioListTile.adaptive(
); enabled: !state.isLoading,
if (consent != OkCancelResult.ok) return; value: server,
} radioScaleFactor: 2,
return ssoLoginAction(); secondary: IconButton(
} icon: const Icon(Icons.info_outlined),
context.push( onPressed: () {},
'${GoRouter.of(context).routeInformationProvider.value.uri.path}/login', ),
extra: client, title: Text(server.name ?? 'Unknown'),
); subtitle: Column(
} catch (e) { spacing: 4.0,
setState( crossAxisAlignment: CrossAxisAlignment.start,
() => error = (e).toLocalizedString( mainAxisSize: MainAxisSize.min,
context, children: [
ExceptionContext.checkHomeserver, if (server.languages?.isNotEmpty == true)
), Row(
); spacing: 4.0,
} finally { children: server.languages!
if (mounted) { .map(
setState(() => isLoading = false); (language) => Material(
} borderRadius: BorderRadius.circular(
} AppConfig.borderRadius,
} ),
color:
List<LoginFlow>? loginFlows; theme.colorScheme.tertiaryContainer,
child: Padding(
bool _supportsFlow(String flowType) => padding: const EdgeInsets.symmetric(
loginFlows?.any((flow) => flow.type == flowType) ?? false; horizontal: 6.0,
vertical: 3.0,
bool get supportsSso => _supportsFlow('m.login.sso'); ),
child: Text(
bool isDefaultPlatform = language,
(PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS); style: TextStyle(
fontSize: 10,
bool get supportsPasswordLogin => _supportsFlow('m.login.password'); color: theme.colorScheme
.onTertiaryContainer,
void ssoLoginAction() async { ),
final redirectUrl = kIsWeb ),
? Uri.parse(html.window.location.href) ),
.resolveUri( ),
Uri(pathSegments: ['auth.html']), )
) .toList(),
.toString() ),
: isDefaultPlatform if (server.features?.isNotEmpty == true)
? '${AppConfig.appOpenUrlScheme.toLowerCase()}://login' Row(
: 'http://localhost:3001//login'; spacing: 4.0,
final client = await Matrix.of(context).getLoginClient(); children: server.features!
final url = client.homeserver!.replace( .map(
path: '/_matrix/client/v3/login/sso/redirect', (feature) => Material(
queryParameters: {'redirectUrl': redirectUrl}, 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 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
void initState() {
_checkTorBrowser();
super.initState();
}
@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:
launchUrlString(AppConfig.privacyUrl);
case MoreLoginActions.about:
PlatformInfos.showDialog(context);
}
} }
} }
enum MoreLoginActions { importBackup, privacy, about } enum HomeserverPickerType { login, register }
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,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<MoreLoginActions>(
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),
),
],
),
),
],
),
),
),
);
},
),
);
}
}

View file

@ -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<HomeserverPickerViewModel> {
bool isLoading = false;
final TextEditingController filterTextController = TextEditingController();
String? error;
List<PublicHomeserverData>? 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<String, dynamic>;
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<void> 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<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: 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);
}

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

View file

@ -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<IntroPageViewModel> createState() => IntroPageViewModelState();
}
class IntroPageViewModelState extends State<IntroPageViewModel> {
bool isTorBrowser = false;
bool isLoading = false;
String? error;
@override
void initState() {
_checkTorBrowser();
super.initState();
}
Future<void> _checkTorBrowser() async {
if (!kIsWeb) return;
final isTor = await TorBrowserDetector.isTorBrowser;
isTorBrowser = isTor;
}
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) 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 }

View file

@ -10,12 +10,14 @@ import 'package:fluffychat/utils/platform_infos.dart';
class LoginScaffold extends StatelessWidget { class LoginScaffold extends StatelessWidget {
final Widget body; final Widget body;
final AppBar? appBar; final AppBar? appBar;
final Widget? bottomNavigationBar;
final bool enforceMobileMode; final bool enforceMobileMode;
const LoginScaffold({ const LoginScaffold({
super.key, super.key,
required this.body, required this.body,
this.appBar, this.appBar,
this.bottomNavigationBar,
this.enforceMobileMode = false, this.enforceMobileMode = false,
}); });
@ -30,6 +32,7 @@ class LoginScaffold extends StatelessWidget {
key: const Key('LoginScaffold'), key: const Key('LoginScaffold'),
appBar: appBar, appBar: appBar,
body: SafeArea(child: body), body: SafeArea(child: body),
bottomNavigationBar: bottomNavigationBar,
); );
} }
return Container( return Container(
@ -64,6 +67,7 @@ class LoginScaffold extends StatelessWidget {
key: const Key('LoginScaffold'), key: const Key('LoginScaffold'),
appBar: appBar, appBar: appBar,
body: SafeArea(child: body), body: SafeArea(child: body),
bottomNavigationBar: bottomNavigationBar,
), ),
), ),
), ),