Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
import 'package:tatchat/pages/bootstrap/widgets/new_passphrase_view.dart';
import 'package:tatchat/pages/bootstrap/widgets/restore_bootstrap_view.dart';
import 'package:tatchat/pages/bootstrap/widgets/store_recovery_key_view.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/layouts/login_scaffold.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/view_model_builder.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
class BootstrapPage extends StatelessWidget {
final bool reset;
const BootstrapPage({required this.reset, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ViewModelBuilder(
create: () =>
BootstrapViewModel(client: Matrix.of(context).client, reset: reset),
builder: (context, viewModel, _) {
final cryptoIdentityState = viewModel.value.cryptoIdentityState;
if (cryptoIdentityState?.connected == true && !reset) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => viewModel.goToRoomsPageAfterSuccess(context),
);
}
final title = cryptoIdentityState == null
? L10n.of(context).loadingPleaseWait
: cryptoIdentityState.initialized && !viewModel.value.reset
? L10n.of(context).restoreCryptoIdentity
: viewModel.value.reset
? viewModel.value.recoveryKey != null
? L10n.of(context).youAreReadyToStart
: L10n.of(context).resetCryptoIdentity
: L10n.of(context).setUpCryptoIdentity;
return LoginScaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
leading: viewModel.value.recoveryKey != null
? null
: CloseButton(
onPressed: () async {
if (!reset) {
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),
actions: [
if (viewModel.value.recoveryKey != null)
Padding(
padding: EdgeInsets.all(8),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: EdgeInsets.symmetric(horizontal: 16),
),
onPressed: () => context.go('/rooms'),
child: Text(L10n.of(context).continueText),
),
),
],
),
body: cryptoIdentityState == null
? Center(child: CircularProgressIndicator.adaptive())
: !cryptoIdentityState.initialized || viewModel.value.reset
? viewModel.value.recoveryKey == null
? NewPassphraseView(viewModel)
: StoreRecoveryKeyView(viewModel)
: RestoreBootstrapView(viewModel),
);
},
);
}
}

View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
class BootstrapViewModelState {
String? recoveryKey;
bool isLoading = false;
Object? unlockWithError;
({
bool connected,
bool crossSigningEnabled,
bool initialized,
bool keyBackupEnabled,
})?
cryptoIdentityState;
bool reset = false;
KeyVerification? keyVerification;
List<DeviceKeys>? connectedDevices;
bool obscureText = true;
bool waitingForSecrets = false;
bool noSecretsreceived = false;
bool newPassphraseEqualsRepeatPassphrase = false;
bool newPassphraseLongEnough = false;
bool newPassphraseUpperAndLowerCase = false;
bool newPassphraseSpecialCharacters = false;
bool newPassphraseNumbers = false;
bool passphraseOrRecoveryKeyEntered = false;
bool recoveryKeyDownloaded = false;
bool recoveryKeyStoredInSecureStorage = false;
}

View File

@@ -0,0 +1,302 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:file_picker/file_picker.dart';
import 'package:tatchat/utils/error_reporter.dart';
import 'package:tatchat/utils/localized_exception_extension.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'bootstrap_state.dart';
class BootstrapViewModel extends ValueNotifier<BootstrapViewModelState> {
final Client client;
final bool reset;
final TextEditingController enterPassphraseOrRecovController =
TextEditingController();
final TextEditingController newPassphraseController = TextEditingController();
final TextEditingController repeatPassphraseController =
TextEditingController();
final ScrollController devicesScrollController = ScrollController();
BootstrapViewModel({required this.client, required this.reset})
: super(BootstrapViewModelState()..reset = reset) {
_init();
}
@override
void dispose() {
_cancelKeyVerification();
enterPassphraseOrRecovController.dispose();
newPassphraseController.dispose();
repeatPassphraseController.dispose();
devicesScrollController.dispose();
super.dispose();
}
void _checkCanCreatePassphrase([_]) {
final passphrase = newPassphraseController.text;
value.newPassphraseEqualsRepeatPassphrase =
passphrase.isNotEmpty && passphrase == repeatPassphraseController.text;
value.newPassphraseLongEnough = passphrase.length >= 12;
value.newPassphraseUpperAndLowerCase =
passphrase.contains(RegExp(r'[A-Z]')) &&
passphrase.contains(RegExp(r'[a-z]'));
value.newPassphraseSpecialCharacters = passphrase.contains(
RegExp(r'[!@#$%^&*(),.?":{}|<>]'),
);
value.newPassphraseNumbers = passphrase.contains(RegExp(r'\d'));
notifyListeners();
}
Future<void> retryKeyVerification() async {
value.noSecretsreceived = false;
value.keyVerification = await client.userDeviceKeys[client.userID!]!
.startVerification();
value.keyVerification?.onUpdate = _onKeyVerificationUpdate;
notifyListeners();
}
Future<void> _init() async {
final state = value.cryptoIdentityState = await client
.getCryptoIdentityState();
newPassphraseController.addListener(_checkCanCreatePassphrase);
repeatPassphraseController.addListener(_checkCanCreatePassphrase);
enterPassphraseOrRecovController.addListener(
_passphraseOrRecoveryKeyEntered,
);
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();
value.keyVerification?.onUpdate = _onKeyVerificationUpdate;
}
if (supportsSecureStorage) {
try {
final keyFromSecureStorage = await FlutterSecureStorage().read(
key: _secureStorageKey,
);
if (keyFromSecureStorage != null) {
enterPassphraseOrRecovController.text = keyFromSecureStorage;
}
} catch (e, s) {
Logs().e('Unable to read key from secure storage', e, s);
}
}
}
notifyListeners();
}
void _passphraseOrRecoveryKeyEntered() {
final passphraseOrRecoveryKeyEntered =
enterPassphraseOrRecovController.text.isNotEmpty;
if (value.passphraseOrRecoveryKeyEntered !=
passphraseOrRecoveryKeyEntered) {
value.passphraseOrRecoveryKeyEntered = passphraseOrRecoveryKeyEntered;
notifyListeners();
}
}
Future<void> _onKeyVerificationUpdate() async {
if (value.keyVerification?.state == KeyVerificationState.done) {
value.waitingForSecrets = true;
value.noSecretsreceived = false;
notifyListeners();
value.cryptoIdentityState = await client.getCryptoIdentityState();
var tries = 0;
const max = 10;
while (value.cryptoIdentityState?.connected != true) {
Logs().d('Waiting for secrets... [$tries/$max]');
if (tries >= max) return;
await Future.delayed(const Duration(seconds: 1));
value.cryptoIdentityState = await client.getCryptoIdentityState();
tries++;
}
if (value.cryptoIdentityState?.connected != true) {
value.waitingForSecrets = false;
value.noSecretsreceived = true;
}
}
notifyListeners();
}
Future<void> setOrSkipPassphrase(
String? passphrase,
BuildContext context,
) async {
value.isLoading = true;
notifyListeners();
try {
value.recoveryKey = await client.initCryptoIdentity(
passphrase: passphrase,
wipeCrossSigning: !reset,
wipeKeyBackup: !reset,
wipeSecureStorage: !reset,
setupOnlineKeyBackup: !reset,
setupMasterKey: !reset,
setupSelfSigningKey: !reset,
setupUserSigningKey: !reset,
);
} catch (e, s) {
if (!context.mounted) return;
ErrorReporter(
context,
'Unable to init crypto identity',
).onErrorCallback(e, s);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
value.isLoading = false;
}
notifyListeners();
}
void _cancelKeyVerification() {
final keyVerification = value.keyVerification;
if (keyVerification != null &&
keyVerification.state != KeyVerificationState.done &&
keyVerification.state != KeyVerificationState.error) {
keyVerification.cancel();
}
}
bool get supportsSecureStorage =>
PlatformInfos.isMobile || PlatformInfos.isDesktop;
Future<void> unlock(BuildContext context) async {
final key = enterPassphraseOrRecovController.text.trim();
if (key.isEmpty) return;
_cancelKeyVerification();
value.unlockWithError = null;
value.isLoading = true;
notifyListeners();
try {
await client.restoreCryptoIdentity(key);
value.isLoading = false;
value.cryptoIdentityState = await client.getCryptoIdentityState();
notifyListeners();
return;
} catch (e, s) {
if (e is! InvalidPassphraseException) {
const errorMessage = 'Unexpected error on unlock passphrase';
if (context.mounted) {
ErrorReporter(context, errorMessage).onErrorCallback(e, s);
} else {
Logs().wtf(errorMessage, e, s);
}
}
value.isLoading = false;
value.unlockWithError = e;
notifyListeners();
if (supportsSecureStorage) {
await FlutterSecureStorage().delete(key: _secureStorageKey);
}
return;
}
}
void goToRoomsPageAfterSuccess(BuildContext context) {
for (final room in client.rooms) {
final lastEvent = room.lastEvent;
if (lastEvent == null ||
lastEvent.messageType != MessageTypes.BadEncrypted ||
lastEvent.content['can_request_session'] != true) {
continue;
}
final sessionId = lastEvent.content.tryGet<String>('session_id');
final senderKey = lastEvent.content.tryGet<String>('sender_key');
if (sessionId != null && senderKey != null) {
client.encryption?.keyManager.maybeAutoRequest(
room.id,
sessionId,
senderKey,
);
}
}
context.go('/rooms');
}
void toggleObscureText() {
value.obscureText = !value.obscureText;
notifyListeners();
}
void startResetAccount() {
value.reset = true;
notifyListeners();
}
String get _secureStorageKey => 'ssss_recovery_key_${client.userID}';
Future<void> openRecoveryKeyFile(BuildContext context) async {
final result = await FilePicker.pickFile(
allowedExtensions: ['txt'],
type: FileType.custom,
);
final file = result?.xFile;
if (file == null) return;
try {
final key = await file.readAsString();
enterPassphraseOrRecovController.text = key;
} catch (e, s) {
Logs().d('Unable to read recovery key file', e, s);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
}
}
if (context.mounted) await unlock(context);
}
Future<void> toggleRecoveryKeyDownloaded(
bool? downloaded,
BuildContext context,
) async {
final path = await FilePicker.saveFile(
fileName:
'FluffyChat-Recovery-Key-${DateTime.now().toIso8601String()}.txt',
bytes: Uint8List.fromList(value.recoveryKey!.codeUnits),
);
if (path == null) return;
value.recoveryKeyDownloaded = downloaded == true;
notifyListeners();
}
Future<void> toggleRecoveryKeyStoredInSecureStorage(bool? stored) async {
if (stored == true) {
await FlutterSecureStorage().write(
key: _secureStorageKey,
value: value.recoveryKey,
);
} else {
await FlutterSecureStorage().delete(key: _secureStorageKey);
}
value.recoveryKeyStoredInSecureStorage = stored == true;
notifyListeners();
}
}

View File

@@ -0,0 +1,129 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
import 'package:material_ui/material_ui.dart';
class NewPassphraseView extends StatelessWidget {
final BootstrapViewModel viewModel;
const NewPassphraseView(this.viewModel, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canCreatePassphrase =
viewModel.value.newPassphraseEqualsRepeatPassphrase &&
viewModel.value.newPassphraseNumbers &&
viewModel.value.newPassphraseSpecialCharacters &&
viewModel.value.newPassphraseUpperAndLowerCase &&
viewModel.value.newPassphraseLongEnough;
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Text(L10n.of(context).newPassphraseDescription, textAlign: .center),
const SizedBox(height: 16),
TextField(
obscureText: viewModel.value.obscureText,
readOnly: viewModel.value.isLoading,
controller: viewModel.newPassphraseController,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(
viewModel.value.obscureText
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
onPressed: viewModel.toggleObscureText,
),
hintText: L10n.of(context).newPassphrase,
),
),
const SizedBox(height: 16),
TextField(
obscureText: viewModel.value.obscureText,
readOnly: viewModel.value.isLoading,
controller: viewModel.repeatPassphraseController,
decoration: InputDecoration(
hintText: L10n.of(context).repeatPassphrase,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: canCreatePassphrase && !viewModel.value.isLoading
? () => viewModel.setOrSkipPassphrase(
viewModel.newPassphraseController.text,
context,
)
: null,
child: viewModel.value.isLoading
? CircularProgressIndicator.adaptive()
: Text(L10n.of(context).continueText),
),
const SizedBox(height: 16),
TextButton(
onPressed: viewModel.value.isLoading
? null
: () => viewModel.setOrSkipPassphrase(null, context),
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
child: Text(L10n.of(context).skip),
),
const SizedBox(height: 16),
_PassphraseCheckListTile(
checked: viewModel.value.newPassphraseEqualsRepeatPassphrase,
label: L10n.of(context).passphrasesMatch,
),
const SizedBox(height: 16),
_PassphraseCheckListTile(
checked: viewModel.value.newPassphraseLongEnough,
label: L10n.of(context).passphraseLengthRequirement,
),
const SizedBox(height: 16),
_PassphraseCheckListTile(
checked: viewModel.value.newPassphraseUpperAndLowerCase,
label: L10n.of(context).passphraseUpperAndLowerCaseRequirement,
),
const SizedBox(height: 16),
_PassphraseCheckListTile(
checked: viewModel.value.newPassphraseSpecialCharacters,
label: L10n.of(context).passphraseSpecialCharactersRequirement,
),
const SizedBox(height: 16),
_PassphraseCheckListTile(
checked: viewModel.value.newPassphraseNumbers,
label: L10n.of(context).passphraseNumberRequirement,
),
],
);
}
}
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)),
],
);
}
}

View File

@@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
import 'package:tatchat/pages/key_verification/key_verification_dialog.dart';
import 'package:tatchat/utils/date_time_extension.dart';
import 'package:tatchat/utils/localized_exception_extension.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/device_extension.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:matrix/matrix.dart';
class RestoreBootstrapView extends StatelessWidget {
final BootstrapViewModel viewModel;
const RestoreBootstrapView(this.viewModel, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final keyVerification = viewModel.value.keyVerification;
if (keyVerification != null) {
Logs().v('Key verification state:', keyVerification.state);
if (keyVerification.state == KeyVerificationState.askSas) {
WidgetsBinding.instance.addPostFrameCallback((_) {
KeyVerificationDialog(request: keyVerification).show(context);
});
}
}
final devices =
viewModel.value.connectedDevices
?.map(
(device) => (
title: device.displayname,
lastActive: device.lastActive,
icon: device.icon,
),
)
.toList() ??
[];
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
if (keyVerification != null) ...[
ListTile(
leading:
(keyVerification.state == KeyVerificationState.error ||
viewModel.value.noSecretsreceived)
? IconButton(
onPressed: viewModel.value.isLoading
? null
: viewModel.retryKeyVerification,
tooltip: L10n.of(context).tryAgain,
icon: Icon(Icons.refresh_outlined),
)
: Padding(
padding: const EdgeInsets.all(10.0),
child: CircularProgressIndicator.adaptive(),
),
minLeadingWidth: 40,
title: Text(
viewModel.value.waitingForSecrets
? L10n.of(context).waitingForKeys
: viewModel.value.noSecretsreceived
? L10n.of(context).noKeysTransmitted
: L10n.of(context).restoreBootstrapDevicesDescription,
style: TextStyle(fontSize: 14),
),
),
const SizedBox(height: 16),
Material(
color: theme.colorScheme.surfaceContainer,
clipBehavior: Clip.hardEdge,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 128),
child: Scrollbar(
thumbVisibility: true,
trackVisibility: true,
controller: viewModel.devicesScrollController,
child: ListView.builder(
controller: viewModel.devicesScrollController,
shrinkWrap: true,
itemCount: devices.length,
itemBuilder: (context, i) => ListTile(
leading: CircleAvatar(
foregroundColor: theme.colorScheme.onPrimary,
backgroundColor: theme.colorScheme.primary,
child: Icon(devices[i].icon),
),
title: Text(
devices[i].title,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 14),
),
subtitle: Text(
L10n.of(context).lastActiveAgo(
devices[i].lastActive.localizedTime(context),
),
style: TextStyle(fontSize: 11),
),
),
),
),
),
),
Row(
children: [
Expanded(child: Divider(height: 64)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(L10n.of(context).or),
),
Expanded(child: Divider(height: 64)),
],
),
] else ...[
Text(
L10n.of(context).restoreBootstrapEmptyDevicesDescription,
textAlign: .center,
),
const SizedBox(height: 32),
],
TextField(
readOnly: viewModel.value.isLoading,
obscureText: viewModel.value.obscureText,
controller: viewModel.enterPassphraseOrRecovController,
minLines: 1,
maxLines: viewModel.value.obscureText ? 1 : 4,
onSubmitted: (_) => viewModel.unlock(context),
decoration: InputDecoration(
hintText: L10n.of(context).passphraseOrKey,
prefixIcon: IconButton(
icon: Icon(
viewModel.value.obscureText
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: viewModel.toggleObscureText,
),
errorText: viewModel.value.unlockWithError?.toLocalizedString(
context,
),
errorMaxLines: 4,
suffixIcon: viewModel.value.isLoading
? SizedBox.square(
dimension: 32,
child: Center(
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
),
)
: TextButton(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
),
),
onPressed: viewModel.value.passphraseOrRecoveryKeyEntered
? () => viewModel.unlock(context)
: () => viewModel.openRecoveryKeyFile(context),
child: Text(
viewModel.value.passphraseOrRecoveryKeyEntered
? L10n.of(context).unlock
: L10n.of(context).openFile,
),
),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () async {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).warning,
message: L10n.of(context).resetAccountWarning,
isDestructive: true,
okLabel: L10n.of(context).resetAccount,
);
if (consent != OkCancelResult.ok) return;
if (!context.mounted) return;
viewModel.startResetAccount();
},
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
child: Text(L10n.of(context).resetAccount),
),
],
);
}
}

View File

@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
import 'package:tatchat/utils/tatu_share.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:material_ui/material_ui.dart';
class StoreRecoveryKeyView extends StatelessWidget {
final BootstrapViewModel viewModel;
const StoreRecoveryKeyView(this.viewModel, {super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Text(L10n.of(context).storeRecoveryKeyDescription),
const SizedBox(height: 16),
TextField(
// TODO: Dispose this
controller: TextEditingController(text: viewModel.value.recoveryKey),
readOnly: true,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(Icons.copy_outlined),
onPressed: () =>
TatuShare.share(viewModel.value.recoveryKey!, context),
),
),
),
const SizedBox(height: 16),
CheckboxListTile.adaptive(
value: viewModel.value.recoveryKeyDownloaded,
onChanged: (copied) =>
viewModel.toggleRecoveryKeyDownloaded(copied, context),
title: Text(L10n.of(context).saveAsFile),
),
if (viewModel.supportsSecureStorage)
CheckboxListTile.adaptive(
value: viewModel.value.recoveryKeyStoredInSecureStorage,
onChanged: viewModel.toggleRecoveryKeyStoredInSecureStorage,
title: Text(_getSecureStorageLocalizedName(context)),
),
],
);
}
String _getSecureStorageLocalizedName(BuildContext context) {
if (PlatformInfos.isAndroid) {
return L10n.of(context).storeInAndroidKeystore;
}
if (PlatformInfos.isIOS || PlatformInfos.isMacOS) {
return L10n.of(context).storeInAppleKeyChain;
}
return L10n.of(context).storeSecurlyOnThisDevice;
}
}