Compare commits
1 commit
main
...
krille/new
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8594d2292b |
10 changed files with 783 additions and 484 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
children: server.languages!
|
||||||
|
.map(
|
||||||
|
(language) => Material(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppConfig.borderRadius,
|
||||||
|
),
|
||||||
|
color:
|
||||||
|
theme.colorScheme.tertiaryContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6.0,
|
||||||
|
vertical: 3.0,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
language,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colorScheme
|
||||||
|
.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
if (server.features?.isNotEmpty == true)
|
||||||
|
Row(
|
||||||
|
spacing: 4.0,
|
||||||
|
children: server.features!
|
||||||
|
.map(
|
||||||
|
(feature) => Material(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppConfig.borderRadius,
|
||||||
|
),
|
||||||
|
color: theme
|
||||||
|
.colorScheme.secondaryContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6.0,
|
||||||
|
vertical: 3.0,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
feature,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colorScheme
|
||||||
|
.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
server.description ?? 'A general homeserver',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
},
|
||||||
if (mounted) {
|
),
|
||||||
setState(() => isLoading = false);
|
),
|
||||||
}
|
bottomNavigationBar: AnimatedSize(
|
||||||
}
|
duration: FluffyThemes.animationDuration,
|
||||||
}
|
curve: FluffyThemes.animationCurve,
|
||||||
|
child: selectedHomserver == null
|
||||||
List<LoginFlow>? loginFlows;
|
? const SizedBox.shrink()
|
||||||
|
: Material(
|
||||||
bool _supportsFlow(String flowType) =>
|
elevation: 8,
|
||||||
loginFlows?.any((flow) => flow.type == flowType) ?? false;
|
shadowColor: theme.appBarTheme.shadowColor,
|
||||||
|
child: Padding(
|
||||||
bool get supportsSso => _supportsFlow('m.login.sso');
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: ElevatedButton(
|
||||||
bool isDefaultPlatform =
|
onPressed: state.isLoading
|
||||||
(PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS);
|
? null
|
||||||
|
: () => state
|
||||||
bool get supportsPasswordLogin => _supportsFlow('m.login.password');
|
.checkHomeserverAction(selectedHomserver.name!),
|
||||||
|
child: state.isLoading
|
||||||
void ssoLoginAction() async {
|
? const CircularProgressIndicator.adaptive()
|
||||||
final redirectUrl = kIsWeb
|
: Text(L10n.of(context).continueText),
|
||||||
? 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
|
|
||||||
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 }
|
|
||||||
|
|
||||||
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'],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HomeserverPickerType { login, register }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
232
lib/pages/homeserver_picker/homeserver_picker_view_model.dart
Normal file
232
lib/pages/homeserver_picker/homeserver_picker_view_model.dart
Normal 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);
|
||||||
|
}
|
||||||
79
lib/pages/homeserver_picker/public_homeserver_data.dart
Normal file
79
lib/pages/homeserver_picker/public_homeserver_data.dart
Normal 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'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
lib/pages/intro/intro_page.dart
Normal file
180
lib/pages/intro/intro_page.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
lib/pages/intro/intro_page_view_model.dart
Normal file
89
lib/pages/intro/intro_page_view_model.dart
Normal 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 }
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue