reorganize and rename widgets

This commit is contained in:
Henry Hiles 2026-11-09 17:25:02 -05:00
commit d5f29dc344
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
2 changed files with 568 additions and 547 deletions

View file

@ -0,0 +1,145 @@
import "dart:async";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/gomuks_exception.dart";
import "package:nexus/models/recovery_key.dart";
/// Shows a newly generated recovery key, and once the user confirms they've
/// saved it, resets encryption with it. Pops with true if that succeeded.
class const RecoveryKeySheet(
final RecoveryKey recoveryKey, {
required final bool isReset,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final saved = useState(false);
final isLoading = useState(false);
final error = useState<String?>(null);
final needsApproval = useState(false);
final approvalUrl = useState<Uri?>(null);
final colorScheme = Theme.of(context).colorScheme;
Future<void> reset() async {
if (isLoading.value) return;
isLoading.value = true;
error.value = null;
try {
await ref
.read(ClientController.provider.notifier)
.resetEncryption(recoveryKey);
if (context.mounted) Navigator.of(context).pop(true);
} catch (resetError) {
if (resetError case GomuksException(statusCode: 401)) {
// Open the approval page straight away the first time.
if (!needsApproval.value) {
needsApproval.value = true;
final homeserver = ref
.read(ClientStateController.provider)
?.homeserverUrl;
final url = homeserver == null
? null
: await ref
.read(ClientController.provider.notifier)
.getCrossSigningResetUrl(.parse(homeserver))
.onError((_, _) => null);
if (!context.mounted || url == null) return;
approvalUrl.value = url;
await ref.read(LaunchHelper.provider).launchUrl(url);
}
} else {
error.value = resetError.toString();
}
} finally {
if (context.mounted) isLoading.value = false;
}
}
// Approval happens on a web page that can't redirect back, so keep retrying
// in the background until it goes through, or the sheet is closed.
useEffect(() {
if (!needsApproval.value) return null;
return Timer.periodic(const .new(seconds: 3), (_) => reset()).cancel;
}, [needsApproval.value]);
return SheetLayout(
title: const Text("Save Your Recovery Key"),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text("Cancel"),
),
TextButton(
onPressed: !saved.value || isLoading.value ? null : reset,
style: isReset
? TextButton.styleFrom(foregroundColor: colorScheme.error)
: null,
child: Text(
needsApproval.value
? "Try Again"
: isReset
? "Reset"
: "Set Up",
),
),
],
children: [
const Text(
"This is your new recovery key. Store it somewhere safe, like a password manager. You'll need it to verify new sessions.",
),
Card.filled(
child: ListTile(
title: SelectableText(
recoveryKey.recoveryKey,
style: const .new(fontFamily: "bundled-mono"),
),
trailing: IconButton(
tooltip: "Copy recovery key",
onPressed: () =>
Clipboard.setData(.new(text: recoveryKey.recoveryKey)),
icon: const Icon(Icons.copy),
),
),
),
CheckboxListTile(
value: saved.value,
onChanged: (value) => saved.value = value ?? false,
title: const Text("I've saved my recovery key"),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: needsApproval.value
? Column(
crossAxisAlignment: .stretch,
spacing: 8,
children: [
const Text(
"Your homeserver needs you to approve resetting your identity in your account settings. Once you've approved it, come back here and the reset will finish automatically.",
),
if (approvalUrl.value case final url?)
OutlinedButton.icon(
onPressed: () =>
ref.read(LaunchHelper.provider).launchUrl(url),
icon: const Icon(Icons.open_in_new),
label: const Text("Approve in account settings"),
),
],
)
: const SizedBox(width: .infinity),
),
if (error.value case final message?)
Text(message, style: .new(color: colorScheme.error)),
],
);
}
}

View file

@ -0,0 +1,161 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/main.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/widgets/encryption/recovery_key_sheet.dart";
class const ResetEncryptionSheet({
/// Whether this is shown as a page rather than in a sheet, in which case it
/// can't be cancelled, and is replaced once encryption is set up.
final bool isPage = false,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final verificationState = ref.watch(
ClientStateController.provider.select(
(state) => state?.verificationState,
),
);
final isReset = verificationState?.hasCrossSigning ?? false;
final usePassphrase = useState(false);
final passphraseController = useTextEditingController();
final confirmationController = useTextEditingController();
final passphrase = useValueListenable(passphraseController).text;
final confirmation = useValueListenable(confirmationController).text;
final mismatch = confirmation.isNotEmpty && confirmation != passphrase;
final isLoading = useState(false);
final error = useState<String?>(null);
final colorScheme = Theme.of(context).colorScheme;
return SheetLayout(
title: Text(isReset ? "Reset Encryption Identity" : "Set Up Encryption"),
actions: [
if (!isPage)
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed:
isLoading.value ||
(usePassphrase.value &&
(passphrase.isEmpty || passphrase != confirmation))
? null
: () async {
isLoading.value = true;
error.value = null;
try {
final recoveryKey = await ref
.read(ClientController.provider.notifier)
.generateRecoveryKey(
usePassphrase.value ? passphrase : "",
);
if (!context.mounted) return;
final done = await context.showAdaptiveSheet<bool>(
(_) => RecoveryKeySheet(recoveryKey, isReset: isReset),
);
if (done != true) return;
if (context.mounted && !isPage) Navigator.of(context).pop();
// Shown from the root navigator, because on the verify page,
// this is replaced by the chat once the session is verified.
navigatorKey.currentContext?.showAdaptiveSheet(
(context) => SheetLayout(
title: Text(
isReset ? "Identity Reset" : "Encryption Set Up",
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Done"),
),
],
children: [
Text(
isReset
? "Your encryption identity has been reset, and this session is verified. You'll need to verify your other sessions again."
: "Encryption is set up, and this session is verified. Use your recovery key to verify your other sessions.",
),
],
),
);
} catch (generateError) {
error.value = generateError.toString();
} finally {
if (context.mounted) isLoading.value = false;
}
},
child: Text(isReset ? "Continue" : "Set Up"),
),
],
children: [
if (verificationState case VerificationState(
hasCrossSigning: true,
hasSecretStorage: false,
))
const Text(
"Your account has an encryption identity, but no recovery key. If you're still signed in on another client, you can set up a recovery key there instead, then restart Nexus and use it to verify. Otherwise, continue with the reset:",
),
Text(
isReset
? "This replaces your encryption identity with a new one. Your other sessions will need to be verified again, and any messages you can't currently decrypt may become unreadable."
: "Set up encryption so you can verify your other sessions, and get back your encrypted messages if you lose access to this one.",
),
Column(
children: [
SwitchListTile(
secondary: const Icon(Icons.password),
title: const Text("Use a passphrase"),
subtitle: const Text("Optional, as well as your recovery key"),
value: usePassphrase.value,
onChanged: (value) => usePassphrase.value = value,
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: usePassphrase.value
? Padding(
padding: const .only(top: 12),
child: Column(
crossAxisAlignment: .stretch,
spacing: 12,
children: [
const Text(
"Your passphrase can be used to gain access to your E2EE messages, so make it long and unique. Your passphrase should ideally not be the same as your password.",
),
TextField(
controller: passphraseController,
obscureText: true,
decoration: const .new(labelText: "Passphrase"),
),
TextField(
controller: confirmationController,
obscureText: true,
decoration: .new(
labelText: "Confirm passphrase",
errorText: mismatch
? "Passphrases don't match"
: null,
),
),
],
),
)
: const SizedBox(width: .infinity),
),
],
),
if (error.value case final message?)
Text(message, style: .new(color: colorScheme.error)),
],
);
}
}