Compare commits
1 commit
main
...
krille/new
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8520bb4550 |
12 changed files with 502 additions and 640 deletions
|
|
@ -7,7 +7,7 @@ import 'package:matrix/matrix.dart';
|
|||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pages/archive/archive.dart';
|
||||
import 'package:fluffychat/pages/bootstrap/bootstrap_dialog.dart';
|
||||
import 'package:fluffychat/pages/bootstrap/bootstrap_page.dart';
|
||||
import 'package:fluffychat/pages/chat/chat.dart';
|
||||
import 'package:fluffychat/pages/chat_access_settings/chat_access_settings_controller.dart';
|
||||
import 'package:fluffychat/pages/chat_details/chat_details.dart';
|
||||
|
|
@ -107,11 +107,8 @@ abstract class AppRoutes {
|
|||
GoRoute(
|
||||
path: '/backup',
|
||||
redirect: loggedOutRedirect,
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
BootstrapDialog(wipe: state.uri.queryParameters['wipe'] == 'true'),
|
||||
),
|
||||
pageBuilder: (context, state) =>
|
||||
defaultPageBuilder(context, state, BootstrapPage()),
|
||||
),
|
||||
ShellRoute(
|
||||
// Never use a transition on the shell route. Changing the PageBuilder
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ abstract class FluffyThemes {
|
|||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2776,5 +2776,45 @@
|
|||
"removeAdminRights": "Remove admin rights",
|
||||
"powerLevel": "Power level",
|
||||
"setPowerLevelDescription": "Power levels define what a member is allowed to do in this room and usually range between 0 and 100.",
|
||||
"owner": "Owner"
|
||||
"owner": "Owner",
|
||||
"restoreBootstrapEmptyDevicesDescription": "Please enter your passphrase or recovery key to verify this device and get access to your encrypted message backup.",
|
||||
"@restoreBootstrapEmptyDevicesDescription": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"restoreBootstrapDevicesDescription": "Verify this device with one of your active sessions to get access to your encrypted message backup:",
|
||||
"@restoreBootstrapDevicesDescription": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"restoreBootstrapAlternativeDescription": "Alternatively, you can use your passphrase or recovery key to verify your device and unlock your encrypted message backup:",
|
||||
"@restoreBootstrapAlternativeDescription": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"restoreBootstrapHintText": "Passphrase or recovery key",
|
||||
"@restoreBootstrapHintText": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"resetAccount": "Reset account",
|
||||
"@resetAccount": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"restoreCryptoIdentity": "Restore Crypto Identity",
|
||||
"@restoreCryptoIdentity": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"resetCryptoIdentity": "Reset Crypto Identity",
|
||||
"@resetCryptoIdentity": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"setUpCryptoIdentity": "Set Up Crypto Identity",
|
||||
"@setUpCryptoIdentity": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,587 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:matrix/encryption.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/utils/error_reporter.dart';
|
||||
import 'package:fluffychat/utils/fluffy_share.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/utils/sync_status_localization.dart';
|
||||
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
import 'package:fluffychat/widgets/layouts/login_scaffold.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../key_verification/key_verification_dialog.dart';
|
||||
|
||||
class BootstrapDialog extends StatefulWidget {
|
||||
final bool wipe;
|
||||
|
||||
const BootstrapDialog({super.key, this.wipe = false});
|
||||
|
||||
@override
|
||||
BootstrapDialogState createState() => BootstrapDialogState();
|
||||
}
|
||||
|
||||
class BootstrapDialogState extends State<BootstrapDialog> {
|
||||
final TextEditingController _recoveryKeyTextEditingController =
|
||||
TextEditingController();
|
||||
|
||||
Bootstrap? bootstrap;
|
||||
|
||||
String? _recoveryKeyInputError;
|
||||
|
||||
bool _recoveryKeyInputLoading = false;
|
||||
|
||||
String? titleText;
|
||||
|
||||
bool _recoveryKeyStored = false;
|
||||
bool _recoveryKeyCopied = false;
|
||||
|
||||
bool? _storeInSecureStorage = false;
|
||||
|
||||
bool? _wipe;
|
||||
|
||||
String get _secureStorageKey =>
|
||||
'ssss_recovery_key_${bootstrap!.client.userID}';
|
||||
|
||||
bool get _supportsSecureStorage =>
|
||||
PlatformInfos.isMobile || PlatformInfos.isDesktop;
|
||||
|
||||
String _getSecureStorageLocalizedName() {
|
||||
if (PlatformInfos.isAndroid) {
|
||||
return L10n.of(context).storeInAndroidKeystore;
|
||||
}
|
||||
if (PlatformInfos.isIOS || PlatformInfos.isMacOS) {
|
||||
return L10n.of(context).storeInAppleKeyChain;
|
||||
}
|
||||
return L10n.of(context).storeSecurlyOnThisDevice;
|
||||
}
|
||||
|
||||
late final Client client;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
client = Matrix.of(context).client;
|
||||
_createBootstrap(widget.wipe);
|
||||
}
|
||||
|
||||
Future<void> _cancelAction() async {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).skipChatBackup,
|
||||
message: L10n.of(context).skipChatBackupWarning,
|
||||
okLabel: L10n.of(context).skip,
|
||||
isDestructive: true,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!mounted) return;
|
||||
_goBackAction(false);
|
||||
}
|
||||
|
||||
void _goBackAction(bool success) {
|
||||
if (success) _decryptLastEvents();
|
||||
|
||||
context.canPop() ? context.pop(success) : context.go('/rooms');
|
||||
}
|
||||
|
||||
void _decryptLastEvents() {
|
||||
for (final room in client.rooms) {
|
||||
final event = room.lastEvent;
|
||||
if (event != null &&
|
||||
event.type == EventTypes.Encrypted &&
|
||||
event.messageType == MessageTypes.BadEncrypted &&
|
||||
event.content['can_request_session'] == true) {
|
||||
final sessionId = event.content.tryGet<String>('session_id');
|
||||
final senderKey = event.content.tryGet<String>('sender_key');
|
||||
if (sessionId != null && senderKey != null) {
|
||||
room.client.encryption?.keyManager.maybeAutoRequest(
|
||||
room.id,
|
||||
sessionId,
|
||||
senderKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createBootstrap(bool wipe) async {
|
||||
await client.roomsLoading;
|
||||
await client.accountDataLoading;
|
||||
await client.userDeviceKeysLoading;
|
||||
while (client.prevBatch == null) {
|
||||
await client.onSyncStatus.stream.first;
|
||||
}
|
||||
await client.updateUserDeviceKeys();
|
||||
_wipe = wipe;
|
||||
titleText = null;
|
||||
_recoveryKeyStored = false;
|
||||
bootstrap = client.encryption!.bootstrap(onUpdate: (_) => setState(() {}));
|
||||
final key = await const FlutterSecureStorage().read(key: _secureStorageKey);
|
||||
if (key == null) return;
|
||||
_recoveryKeyTextEditingController.text = key;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bootstrap = this.bootstrap;
|
||||
if (bootstrap == null) {
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: CloseButton(onPressed: _cancelAction),
|
||||
title: Text(L10n.of(context).loadingMessages),
|
||||
),
|
||||
body: Center(
|
||||
child: StreamBuilder(
|
||||
stream: client.onSyncStatus.stream,
|
||||
builder: (context, snapshot) {
|
||||
final status = snapshot.data;
|
||||
return Column(
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
CircularProgressIndicator.adaptive(value: status?.progress),
|
||||
if (status != null) Text(status.calcLocalizedString(context)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_wipe ??= widget.wipe;
|
||||
final buttons = <Widget>[];
|
||||
Widget body = const Center(child: CircularProgressIndicator.adaptive());
|
||||
titleText = L10n.of(context).loadingPleaseWait;
|
||||
|
||||
if (bootstrap.newSsssKey?.recoveryKey != null &&
|
||||
_recoveryKeyStored == false) {
|
||||
final key = bootstrap.newSsssKey!.recoveryKey;
|
||||
titleText = L10n.of(context).recoveryKey;
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: CloseButton(onPressed: _cancelAction),
|
||||
title: Text(L10n.of(context).recoveryKey),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.columnWidth * 1.5,
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
trailing: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Icon(
|
||||
Icons.info_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
subtitle: Text(L10n.of(context).chatBackupDescription),
|
||||
),
|
||||
const Divider(height: 32, thickness: 1),
|
||||
TextField(
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
readOnly: true,
|
||||
style: const TextStyle(fontFamily: 'RobotoMono'),
|
||||
controller: TextEditingController(text: key),
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.all(16),
|
||||
suffixIcon: Icon(Icons.key_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_supportsSecureStorage)
|
||||
Semantics(
|
||||
identifier: 'store_in_secure_storage',
|
||||
child: CheckboxListTile.adaptive(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
),
|
||||
value: _storeInSecureStorage,
|
||||
activeColor: theme.colorScheme.primary,
|
||||
onChanged: (b) {
|
||||
setState(() {
|
||||
_storeInSecureStorage = b;
|
||||
});
|
||||
},
|
||||
title: Text(_getSecureStorageLocalizedName()),
|
||||
subtitle: Text(
|
||||
L10n.of(context).storeInSecureStorageDescription,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Semantics(
|
||||
identifier: 'copy_to_clipboard',
|
||||
child: CheckboxListTile.adaptive(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
value: _recoveryKeyCopied,
|
||||
activeColor: theme.colorScheme.primary,
|
||||
onChanged: (b) {
|
||||
FluffyShare.share(key!, context);
|
||||
setState(() => _recoveryKeyCopied = true);
|
||||
},
|
||||
title: Text(L10n.of(context).copyToClipboard),
|
||||
subtitle: Text(L10n.of(context).saveKeyManuallyDescription),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.check_outlined),
|
||||
label: Text(L10n.of(context).next),
|
||||
onPressed:
|
||||
(_recoveryKeyCopied || _storeInSecureStorage == true)
|
||||
? () {
|
||||
if (_storeInSecureStorage == true) {
|
||||
const FlutterSecureStorage().write(
|
||||
key: _secureStorageKey,
|
||||
value: key,
|
||||
);
|
||||
}
|
||||
setState(() => _recoveryKeyStored = true);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
switch (bootstrap.state) {
|
||||
case BootstrapState.loading:
|
||||
break;
|
||||
case BootstrapState.askWipeSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.wipeSsss(_wipe!),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.askBadSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.ignoreBadSecrets(true),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.askUseExistingSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.useExistingSsss(!_wipe!),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.askUnlockSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.unlockedSsss(),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.askNewSsss:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.newSsss(),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.openExistingSsss:
|
||||
_recoveryKeyStored = true;
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: CloseButton(onPressed: _cancelAction),
|
||||
title: Text(L10n.of(context).setupChatBackup),
|
||||
),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.columnWidth * 1.5,
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.info_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
subtitle: Text(
|
||||
L10n.of(context).pleaseEnterRecoveryKeyDescription,
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
TextField(
|
||||
minLines: 1,
|
||||
maxLines: 2,
|
||||
autocorrect: false,
|
||||
readOnly: _recoveryKeyInputLoading,
|
||||
autofillHints: _recoveryKeyInputLoading
|
||||
? null
|
||||
: [AutofillHints.password],
|
||||
controller: _recoveryKeyTextEditingController,
|
||||
style: const TextStyle(fontFamily: 'RobotoMono'),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: theme.textTheme.bodyLarge?.fontFamily,
|
||||
),
|
||||
prefixIcon: const Icon(Icons.key_outlined),
|
||||
labelText: L10n.of(context).recoveryKey,
|
||||
hintText: 'Es** **** **** ****',
|
||||
errorText: _recoveryKeyInputError,
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
iconColor: theme.colorScheme.onPrimary,
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
icon: _recoveryKeyInputLoading
|
||||
? const CircularProgressIndicator.adaptive()
|
||||
: const Icon(Icons.lock_open_outlined),
|
||||
label: Text(L10n.of(context).unlockOldMessages),
|
||||
onPressed: _recoveryKeyInputLoading
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
_recoveryKeyInputError = null;
|
||||
_recoveryKeyInputLoading = true;
|
||||
});
|
||||
try {
|
||||
final key = _recoveryKeyTextEditingController
|
||||
.text
|
||||
.trim();
|
||||
if (key.isEmpty) return;
|
||||
await bootstrap.newSsssKey!.unlock(
|
||||
keyOrPassphrase: key,
|
||||
);
|
||||
await bootstrap.openExistingSsss();
|
||||
Logs().d('SSSS unlocked');
|
||||
if (bootstrap.encryption.crossSigning.enabled) {
|
||||
Logs().v(
|
||||
'Cross signing is already enabled. Try to self-sign',
|
||||
);
|
||||
await bootstrap
|
||||
.client
|
||||
.encryption!
|
||||
.crossSigning
|
||||
.selfSign(recoveryKey: key);
|
||||
Logs().d('Successful selfsigned');
|
||||
}
|
||||
} on InvalidPassphraseException catch (e) {
|
||||
setState(
|
||||
() => _recoveryKeyInputError = e
|
||||
.toLocalizedString(context),
|
||||
);
|
||||
} on FormatException catch (_) {
|
||||
setState(
|
||||
() => _recoveryKeyInputError = L10n.of(
|
||||
context,
|
||||
).wrongRecoveryKey,
|
||||
);
|
||||
} catch (e, s) {
|
||||
ErrorReporter(
|
||||
context,
|
||||
'Unable to open SSSS with recovery key',
|
||||
).onErrorCallback(e, s);
|
||||
setState(
|
||||
() => _recoveryKeyInputError = e
|
||||
.toLocalizedString(context),
|
||||
);
|
||||
} finally {
|
||||
setState(
|
||||
() => _recoveryKeyInputLoading = false,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(L10n.of(context).or),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.cast_connected_outlined),
|
||||
label: Text(L10n.of(context).transferFromAnotherDevice),
|
||||
onPressed: _recoveryKeyInputLoading
|
||||
? null
|
||||
: () async {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).verifyOtherDevice,
|
||||
message: L10n.of(
|
||||
context,
|
||||
).verifyOtherDeviceDescription,
|
||||
okLabel: L10n.of(context).ok,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
final req = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
delay: false,
|
||||
future: () async {
|
||||
await client.updateUserDeviceKeys();
|
||||
return client.userDeviceKeys[client.userID!]!
|
||||
.startVerification();
|
||||
},
|
||||
);
|
||||
if (req.error != null) return;
|
||||
final success = await KeyVerificationDialog(
|
||||
request: req.result!,
|
||||
).show(context);
|
||||
if (success != true) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final allCached =
|
||||
await client.encryption!.keyManager
|
||||
.isCached() &&
|
||||
await client.encryption!.crossSigning
|
||||
.isCached();
|
||||
if (!allCached) {
|
||||
await client
|
||||
.encryption!
|
||||
.ssss
|
||||
.onSecretStored
|
||||
.stream
|
||||
.first;
|
||||
}
|
||||
return;
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!result.isError) _goBackAction(true);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.errorContainer,
|
||||
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||
iconColor: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
label: Text(L10n.of(context).recoveryKeyLost),
|
||||
onPressed: _recoveryKeyInputLoading
|
||||
? null
|
||||
: () async {
|
||||
if (OkCancelResult.ok ==
|
||||
await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context).recoveryKeyLost,
|
||||
message: L10n.of(context).wipeChatBackup,
|
||||
okLabel: L10n.of(context).ok,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
isDestructive: true,
|
||||
)) {
|
||||
setState(() => _createBootstrap(true));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case BootstrapState.askWipeCrossSigning:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.wipeCrossSigning(_wipe!),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.askSetupCrossSigning:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.askSetupCrossSigning(
|
||||
setupMasterKey: true,
|
||||
setupSelfSigningKey: true,
|
||||
setupUserSigningKey: true,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.askWipeOnlineKeyBackup:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.wipeOnlineKeyBackup(_wipe!),
|
||||
);
|
||||
|
||||
break;
|
||||
case BootstrapState.askSetupOnlineKeyBackup:
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => bootstrap.askSetupOnlineKeyBackup(true),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.error:
|
||||
titleText = L10n.of(context).oopsSomethingWentWrong;
|
||||
body = const Icon(Icons.error_outline, color: Colors.red, size: 80);
|
||||
buttons.add(
|
||||
ElevatedButton(
|
||||
onPressed: () => _goBackAction(false),
|
||||
child: Text(L10n.of(context).close),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case BootstrapState.done:
|
||||
titleText = L10n.of(context).everythingReady;
|
||||
body = Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: 120,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
L10n.of(context).yourChatBackupHasBeenSetUp,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
buttons.add(
|
||||
ElevatedButton(
|
||||
onPressed: () => _goBackAction(true),
|
||||
child: Text(L10n.of(context).close),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
leading: CloseButton(onPressed: _cancelAction),
|
||||
title: Text(titleText ?? L10n.of(context).loadingPleaseWait),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [body, const SizedBox(height: 8), ...buttons],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
lib/pages/bootstrap/bootstrap_page.dart
Normal file
110
lib/pages/bootstrap/bootstrap_page.dart
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:fluffychat/pages/bootstrap/widgets/new_passphrase_view.dart';
|
||||
import 'package:fluffychat/pages/bootstrap/widgets/restore_bootstrap_view.dart';
|
||||
import 'package:fluffychat/pages/bootstrap/widgets/store_recovery_key_view.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/device_extension.dart';
|
||||
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:fluffychat/widgets/layouts/login_scaffold.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:fluffychat/widgets/view_model_builder.dart';
|
||||
|
||||
class BootstrapPage extends StatelessWidget {
|
||||
const BootstrapPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder(
|
||||
create: () => BootstrapViewModel(client: Matrix.of(context).client),
|
||||
builder: (context, viewModel, _) {
|
||||
final cryptoIdentityState = viewModel.value.cryptoIdentityState;
|
||||
|
||||
final title = cryptoIdentityState == null
|
||||
? L10n.of(context).loadingPleaseWait
|
||||
: cryptoIdentityState.initialized && !viewModel.value.reset
|
||||
? L10n.of(context).restoreCryptoIdentity
|
||||
: viewModel.value.reset
|
||||
? L10n.of(context).resetCryptoIdentity
|
||||
: L10n.of(context).setUpCryptoIdentity;
|
||||
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
leading: CloseButton(
|
||||
onPressed: () async {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).skipChatBackup,
|
||||
message: L10n.of(context).skipChatBackupWarning,
|
||||
okLabel: L10n.of(context).skip,
|
||||
isDestructive: true,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!context.mounted) return;
|
||||
context.go('/rooms');
|
||||
},
|
||||
),
|
||||
title: Text(title),
|
||||
),
|
||||
body: cryptoIdentityState == null
|
||||
? Center(child: CircularProgressIndicator.adaptive())
|
||||
: !cryptoIdentityState.initialized || viewModel.value.reset
|
||||
? viewModel.value.recoveryKey == null
|
||||
? NewPassphraseView()
|
||||
: StoreRecoveryKeyView()
|
||||
: RestoreBootstrapView(
|
||||
onToggleObscureText: viewModel.toggleObscureText,
|
||||
unlockWithRecoveryKey: () async {
|
||||
final success = await viewModel.unlock();
|
||||
if (!success) return;
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Device has been verified and message backup is unlocked!',
|
||||
),
|
||||
),
|
||||
);
|
||||
context.go('/rooms');
|
||||
},
|
||||
recoveryKeyInputController:
|
||||
viewModel.enterPassphraseOrRecovController,
|
||||
obscureText: viewModel.value.obscureText,
|
||||
isLoading: viewModel.value.isLoading,
|
||||
errorText: viewModel.value.unlockWithError?.toLocalizedString(
|
||||
context,
|
||||
),
|
||||
onResetAccount: () async {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).warning,
|
||||
message:
|
||||
'When you reset your account you will lose the access to your old messages forever. All your current devices need to be verified again. Please only perform this action when you have no other devices left to verify your session and you have lost your recovery key and passphrase!',
|
||||
isDestructive: true,
|
||||
okLabel: L10n.of(context).resetAccount,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!context.mounted) return;
|
||||
viewModel.startResetAccount();
|
||||
},
|
||||
devices:
|
||||
viewModel.value.connectedDevices
|
||||
?.map(
|
||||
(device) => (
|
||||
title: device.displayname,
|
||||
lastActive: device.lastActive,
|
||||
icon: device.icon,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/pages/bootstrap/view_model/bootstrap_state.dart
Normal file
14
lib/pages/bootstrap/view_model/bootstrap_state.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import 'package:matrix/encryption.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class BootstrapViewModelState {
|
||||
String? recoveryKey;
|
||||
bool isLoading = false;
|
||||
Object? unlockWithError;
|
||||
({bool connected, bool initialized})? cryptoIdentityState;
|
||||
bool reset = false;
|
||||
KeyVerification? keyVerification;
|
||||
List<DeviceKeys>? connectedDevices;
|
||||
bool recoveryKeyStored = false;
|
||||
bool obscureText = true;
|
||||
}
|
||||
91
lib/pages/bootstrap/view_model/bootstrap_view_model.dart
Normal file
91
lib/pages/bootstrap/view_model/bootstrap_view_model.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:matrix/encryption.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'bootstrap_state.dart';
|
||||
|
||||
class BootstrapViewModel extends ValueNotifier<BootstrapViewModelState> {
|
||||
final Client client;
|
||||
|
||||
final TextEditingController enterPassphraseOrRecovController =
|
||||
TextEditingController();
|
||||
final TextEditingController newPassphraseController = TextEditingController();
|
||||
final TextEditingController repeatPassphraseController =
|
||||
TextEditingController();
|
||||
|
||||
BootstrapViewModel({required this.client})
|
||||
: super(BootstrapViewModelState()) {
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final state = value.cryptoIdentityState = await client
|
||||
.getCryptoIdentityState();
|
||||
if (state.initialized) {
|
||||
if (state.connected) return notifyListeners();
|
||||
|
||||
await client.updateUserDeviceKeys();
|
||||
|
||||
final devices = value.connectedDevices =
|
||||
client.userDeviceKeys[client.userID!]?.deviceKeys.values
|
||||
.where(
|
||||
(device) => device.hasValidSignatureChain(
|
||||
verifiedByTheirMasterKey: true,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
if (devices.isNotEmpty) {
|
||||
value.keyVerification = await client.userDeviceKeys[client.userID!]!
|
||||
.startVerification();
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setOrSkipPassphrase(String? passphrase) async {
|
||||
value.isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
value.recoveryKey = await client.initCryptoIdentity(passphrase: passphrase);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> unlock() async {
|
||||
final key = enterPassphraseOrRecovController.text.trim();
|
||||
if (key.isEmpty) return false;
|
||||
|
||||
value.unlockWithError = null;
|
||||
value.isLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
await client.restoreCryptoIdentity(key);
|
||||
value.isLoading = false;
|
||||
value.cryptoIdentityState = await client.getCryptoIdentityState();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
Logs().d('Unable to unlock', e, s);
|
||||
value.isLoading = false;
|
||||
value.unlockWithError = e;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void setRecoveryKeyStored() {
|
||||
value.recoveryKeyStored = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleObscureText() {
|
||||
value.obscureText = !value.obscureText;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startResetAccount() {
|
||||
value.reset = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
94
lib/pages/bootstrap/widgets/new_passphrase_view.dart
Normal file
94
lib/pages/bootstrap/widgets/new_passphrase_view.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
|
||||
class NewPassphraseView extends StatelessWidget {
|
||||
const NewPassphraseView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ListView(
|
||||
children: [
|
||||
Text(
|
||||
'FluffyChat uses end to end encryption. To not lose your messages, please choose a strong passphrase to secure your crypto identity and your encrypted message backup.',
|
||||
textAlign: .center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.visibility_off_outlined),
|
||||
onPressed: () {},
|
||||
),
|
||||
hintText: 'New passphrase',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(decoration: InputDecoration(hintText: 'Repeat passphrase')),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: Text(L10n.of(context).continueText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
child: Text(L10n.of(context).skip),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: true,
|
||||
label: 'At least 12 characters long.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: false,
|
||||
label: 'Contains uppercase and lowercase characters.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: false,
|
||||
label: 'Contains special characters.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: true,
|
||||
label: 'Contains one numbers.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PassphraseCheckListTile extends StatelessWidget {
|
||||
final String label;
|
||||
final bool checked;
|
||||
const _PassphraseCheckListTile({required this.label, required this.checked});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
Icon(
|
||||
checked ? Icons.check_circle_outlined : Icons.circle_outlined,
|
||||
color: checked
|
||||
? theme.brightness == Brightness.light
|
||||
? Colors.green.shade800
|
||||
: Colors.green.shade300
|
||||
: theme.colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
Text(label, style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
114
lib/pages/bootstrap/widgets/restore_bootstrap_view.dart
Normal file
114
lib/pages/bootstrap/widgets/restore_bootstrap_view.dart
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/utils/date_time_extension.dart';
|
||||
|
||||
class RestoreBootstrapView extends StatelessWidget {
|
||||
final List<({String title, DateTime lastActive, IconData icon})> devices;
|
||||
final TextEditingController recoveryKeyInputController;
|
||||
final bool obscureText, isLoading;
|
||||
final VoidCallback onToggleObscureText, unlockWithRecoveryKey, onResetAccount;
|
||||
final String? errorText;
|
||||
|
||||
const RestoreBootstrapView({
|
||||
super.key,
|
||||
required this.devices,
|
||||
required this.recoveryKeyInputController,
|
||||
required this.obscureText,
|
||||
required this.onToggleObscureText,
|
||||
required this.unlockWithRecoveryKey,
|
||||
required this.isLoading,
|
||||
this.errorText,
|
||||
required this.onResetAccount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
spacing: 16.0,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
Text(
|
||||
devices.isEmpty
|
||||
? L10n.of(context).restoreBootstrapEmptyDevicesDescription
|
||||
: L10n.of(context).restoreBootstrapDevicesDescription,
|
||||
textAlign: .center,
|
||||
),
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 128),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, i) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: theme.colorScheme.surfaceContainer,
|
||||
child: Icon(devices[i].icon),
|
||||
),
|
||||
title: Text(devices[i].title),
|
||||
subtitle: Text(
|
||||
L10n.of(context).lastActiveAgo(
|
||||
devices[i].lastActive.localizedTime(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (devices.isNotEmpty) ...[
|
||||
Divider(height: 32),
|
||||
Text(
|
||||
L10n.of(context).restoreBootstrapAlternativeDescription,
|
||||
textAlign: .center,
|
||||
),
|
||||
],
|
||||
TextField(
|
||||
readOnly: isLoading,
|
||||
obscureText: obscureText,
|
||||
controller: recoveryKeyInputController,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context).restoreBootstrapHintText,
|
||||
prefixIcon: IconButton(
|
||||
icon: Icon(
|
||||
obscureText
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: onToggleObscureText,
|
||||
),
|
||||
errorText: errorText,
|
||||
errorMaxLines: 4,
|
||||
suffixIcon: isLoading
|
||||
? SizedBox.square(
|
||||
dimension: 32,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: Icon(Icons.send_outlined),
|
||||
onPressed: unlockWithRecoveryKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onResetAccount,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
child: Text(L10n.of(context).resetAccount),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
lib/pages/bootstrap/widgets/store_recovery_key_view.dart
Normal file
10
lib/pages/bootstrap/widgets/store_recovery_key_view.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class StoreRecoveryKeyView extends StatelessWidget {
|
||||
const StoreRecoveryKeyView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,30 +3,9 @@ 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,
|
||||
);
|
||||
}
|
||||
PublicHomeserverData? selectedHomeserver;
|
||||
AsyncSnapshot<List<PublicHomeserverData>> publicHomeservers =
|
||||
const AsyncSnapshot.nothing();
|
||||
List<PublicHomeserverData> filteredPublicHomeservers = [];
|
||||
AsyncSnapshot<bool> loginLoading = const AsyncSnapshot.nothing();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,14 +45,13 @@ class SignInViewModel extends ValueNotifier<SignInState> {
|
|||
)) {
|
||||
filteredPublicHomeservers.add(PublicHomeserverData(name: filterText));
|
||||
}
|
||||
|
||||
value = value.copyWith(
|
||||
filteredPublicHomeservers: filteredPublicHomeservers,
|
||||
);
|
||||
value.filteredPublicHomeservers = filteredPublicHomeservers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshPublicHomeservers() async {
|
||||
value = value.copyWith(publicHomeservers: AsyncSnapshot.waiting());
|
||||
notifyListeners();
|
||||
value.publicHomeservers = AsyncSnapshot.waiting();
|
||||
final defaultHomeserverData = PublicHomeserverData(
|
||||
name: AppSettings.defaultHomeserver.value,
|
||||
);
|
||||
|
|
@ -82,30 +81,31 @@ class SignInViewModel extends ValueNotifier<SignInState> {
|
|||
publicHomeservers.insert(0, defaultHomeserverData);
|
||||
}
|
||||
|
||||
value = value.copyWith(
|
||||
selectedHomeserver: value.selectedHomeserver ?? publicHomeservers.first,
|
||||
publicHomeservers: AsyncSnapshot.withData(
|
||||
value.selectedHomeserver =
|
||||
value.selectedHomeserver ?? publicHomeservers.first;
|
||||
value.publicHomeservers = AsyncSnapshot.withData(
|
||||
ConnectionState.done,
|
||||
publicHomeservers,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e, s) {
|
||||
Logs().w('Unable to fetch public homeservers...', e, s);
|
||||
value = value.copyWith(
|
||||
selectedHomeserver: defaultHomeserverData,
|
||||
publicHomeservers: AsyncSnapshot.withData(ConnectionState.done, [
|
||||
value.selectedHomeserver = defaultHomeserverData;
|
||||
value.publicHomeservers = AsyncSnapshot.withData(ConnectionState.done, [
|
||||
defaultHomeserverData,
|
||||
]),
|
||||
);
|
||||
]);
|
||||
notifyListeners();
|
||||
}
|
||||
_filterHomeservers();
|
||||
}
|
||||
|
||||
void selectHomeserver(PublicHomeserverData? publicHomeserverData) {
|
||||
value = value.copyWith(selectedHomeserver: publicHomeserverData);
|
||||
value.selectedHomeserver = publicHomeserverData;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setLoginLoading(AsyncSnapshot<bool> loginLoading) {
|
||||
value = value.copyWith(loginLoading: loginLoading);
|
||||
value.loginLoading = loginLoading;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue