385 lines
14 KiB
Dart
385 lines
14 KiB
Dart
import "package:auto_route/auto_route.dart";
|
|
import "package:flutter/material.dart";
|
|
import "package:flutter/services.dart";
|
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
|
|
import "package:smol_mail/data/providers/providers.dart";
|
|
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
|
import "package:smol_mail/presentation/widgets/primary_button.dart";
|
|
import "package:smol_mail/presentation/widgets/secondary_button.dart";
|
|
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
|
|
import "package:smol_mail/smol/crypto.dart";
|
|
import "package:smol_mail/smol/errors.dart";
|
|
import "package:smol_mail/smol/proto.dart";
|
|
import "package:smol_mail/presentation/theme/app_colors.dart";
|
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
|
|
|
/// First-run flow: create or restore an identity, back the seed up, pin the
|
|
/// home server's key and register an address (SPEC.md §4: registration against
|
|
/// an unpinned server is not permitted).
|
|
@RoutePage()
|
|
class OnboardingScreen extends ConsumerStatefulWidget {
|
|
const OnboardingScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
|
|
}
|
|
|
|
enum _Step { welcome, backup, restore, register }
|
|
|
|
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|
_Step step = _Step.welcome;
|
|
Uint8List? createdMaster;
|
|
bool busy = false;
|
|
// The register step doubles as "pin a key to finish recalling" when a
|
|
// restore's recall can't proceed without one yet — same fields, different
|
|
// framing and default action, not the generic "register a new address" copy.
|
|
bool recallIntent = false;
|
|
// Set alongside recallIntent when we got here from Restore rather than from
|
|
// "Already registered? Recall": there is no well-defined "register a new
|
|
// address instead" fallback for a restored master until its rotation index
|
|
// is known, so that escape hatch is hidden in this case (§2).
|
|
bool restoreIntent = false;
|
|
|
|
final seedController = TextEditingController();
|
|
final restoreAddressController = TextEditingController();
|
|
final addressController = TextEditingController();
|
|
final serverKeyController = TextEditingController();
|
|
final tokenController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
seedController.dispose();
|
|
restoreAddressController.dispose();
|
|
addressController.dispose();
|
|
serverKeyController.dispose();
|
|
tokenController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _showError(Object error) {
|
|
if (!mounted) return;
|
|
// Replace rather than queue: a fresh error must not wait behind a stale
|
|
// one — a queued SnackBar is indistinguishable from "nothing happened".
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
showErrorSnackBar(
|
|
context, error is SmolError ? error.message : error.toString());
|
|
}
|
|
|
|
// Reaching onboarding at all means HomeGuard already found no complete
|
|
// identity+account, so a master still sitting in the store here can only be
|
|
// an abandoned attempt from earlier in this same flow (wrong master, failed
|
|
// recall, "Back") — safe to replace rather than reject.
|
|
// wipe() is fire-and-forget here, like every other store write in this
|
|
// screen (setMaster, pinServer, ...) — Hive updates its in-memory state
|
|
// synchronously and persists to disk in the background, so the identity
|
|
// check right after is already consistent without awaiting the write.
|
|
void _clearAbandonedIdentity() {
|
|
final client = ref.read(clientProvider);
|
|
if (client.identity != null && client.accountAddress() == null) {
|
|
ref.read(storeProvider).wipe();
|
|
}
|
|
}
|
|
|
|
void _createIdentity() {
|
|
_clearAbandonedIdentity();
|
|
final client = ref.read(clientProvider);
|
|
try {
|
|
final master = client.createIdentity();
|
|
setState(() {
|
|
createdMaster = master;
|
|
step = _Step.backup;
|
|
});
|
|
} on Exception catch (err) {
|
|
_showError(err);
|
|
}
|
|
}
|
|
|
|
void _restoreIdentity() {
|
|
setState(() => step = _Step.restore);
|
|
}
|
|
|
|
// §2: a master alone does not say which rotation index a server bound, so
|
|
// restoring resolves the address and walks indices to find it — restore
|
|
// always ends in a recall, never a bare local step.
|
|
Future<void> _submitRestore() async {
|
|
final addressText = restoreAddressController.text.trim();
|
|
if (addressText.isEmpty) {
|
|
_showError(const SmolError("enter the address this master was registered under"));
|
|
return;
|
|
}
|
|
_clearAbandonedIdentity();
|
|
// The register step has its own address field (it also needs a server
|
|
// key, which restore doesn't collect) — carry over what was already
|
|
// typed rather than making the user re-enter it.
|
|
addressController.text = addressText;
|
|
final client = ref.read(clientProvider);
|
|
setState(() => busy = true);
|
|
try {
|
|
await client.restoreAndRecall(seedController.text, addressText);
|
|
if (!mounted) return;
|
|
ref.read(revisionProvider.notifier).bump();
|
|
AutoRouter.of(context).replace(HomeRoute());
|
|
} on Exception catch (err) {
|
|
if (!mounted) return;
|
|
// Most often the host just isn't pinned yet (SPEC.md §4) — the expected
|
|
// state right after a restore, not a dead end — so land on the
|
|
// recall-framed register step to collect a key and retry.
|
|
_showError(err);
|
|
setState(() {
|
|
recallIntent = true;
|
|
restoreIntent = true;
|
|
step = _Step.register;
|
|
});
|
|
} finally {
|
|
if (mounted) setState(() => busy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _submitRegistration() async {
|
|
await _submit(recall: false);
|
|
}
|
|
|
|
// Recall binds a restored or already-created identity to its registered
|
|
// address without re-REGISTER.
|
|
Future<void> _submitRecall() async {
|
|
await _submit(recall: true);
|
|
}
|
|
|
|
Future<void> _submit({required bool recall}) async {
|
|
final client = ref.read(clientProvider);
|
|
setState(() => busy = true);
|
|
try {
|
|
final address = parseAddress(addressController.text);
|
|
// Recall needs a pin too (SPEC.md §4), and the key for it is right
|
|
// here in the form — pin it before either path. An empty field means
|
|
// "already pinned, e.g. by a previous attempt or a preset server".
|
|
final serverKey = serverKeyController.text.trim();
|
|
if (serverKey.isNotEmpty) {
|
|
client.pinServer(address.host, serverKey);
|
|
}
|
|
if (recall && restoreIntent) {
|
|
await client.restoreAndRecall(seedController.text, address.short);
|
|
} else if (recall) {
|
|
await client.recallAccount(address.short);
|
|
} else {
|
|
await client.registerAccount(address.short,
|
|
token: tokenController.text.trim());
|
|
}
|
|
if (mounted) {
|
|
ref.read(revisionProvider.notifier).bump();
|
|
AutoRouter.of(context).replace(HomeRoute());
|
|
}
|
|
} catch (err) {
|
|
_showError(err);
|
|
} finally {
|
|
if (mounted) setState(() => busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 25),
|
|
child: switch (step) {
|
|
_Step.welcome => _welcome(context),
|
|
_Step.backup => _backup(context),
|
|
_Step.restore => _restore(context),
|
|
_Step.register => _register(context),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _header(BuildContext context, String title) => LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final logoWidth =
|
|
(constraints.maxWidth * 0.5).clamp(48.0, 200.0).toDouble();
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: logoWidth,
|
|
child: const Image(
|
|
image: AssetImage("assets/images/bug_logo.png"),
|
|
fit: BoxFit.contain),
|
|
),
|
|
Text("kirakira",
|
|
style: Theme.of(context).textTheme.titleLarge),
|
|
const SizedBox(height: 40),
|
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
Widget _welcome(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_header(context,
|
|
"One keypair is the whole identity: an address, a key and a message."),
|
|
const SizedBox(height: 40),
|
|
PrimaryButton(
|
|
onPressed: _createIdentity,
|
|
child: const Text("Create Identity"),
|
|
),
|
|
const SizedBox(height: 5),
|
|
SecondaryButton(
|
|
text: "Restore From Seed",
|
|
onPressed: _restoreIdentity,
|
|
),
|
|
const SizedBox(height: 80),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _backup(BuildContext context) {
|
|
final master = createdMaster!;
|
|
final masterHex = hex(master);
|
|
final publicKey = ref.read(clientProvider).identity!.publicKey;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_header(context, "Back up this master secret; it is the only secret."),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
"fingerprint:\n${fingerprint(publicKey)}",
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 20),
|
|
SelectableText(
|
|
masterHex,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall!
|
|
.copyWith(color: Theme.of(context).colorScheme.primary),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: masterHex));
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
|
const SnackBar(content: Text("Master secret copied to clipboard")),
|
|
);
|
|
}
|
|
},
|
|
child: const Text("Copy master secret"),
|
|
),
|
|
const SizedBox(height: 40),
|
|
PrimaryButton(
|
|
onPressed: () => setState(() => step = _Step.register),
|
|
child: const Text("I have backed it up"),
|
|
),
|
|
const SizedBox(height: 80),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _restore(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_header(context, "Enter the 32-byte master secret as 64 hex characters."),
|
|
const SizedBox(height: 20),
|
|
TextField(
|
|
controller: seedController,
|
|
decoration: InputDecoration(
|
|
hintText: "64 hex characters",
|
|
filled: true,
|
|
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
|
),
|
|
),
|
|
const SizedBox(height: 15),
|
|
TextField(
|
|
controller: restoreAddressController,
|
|
decoration: InputDecoration(
|
|
labelText: "Address this master was registered under",
|
|
hintText: "alice@example.org",
|
|
filled: true,
|
|
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
|
),
|
|
),
|
|
const SizedBox(height: 30),
|
|
PrimaryButton(
|
|
onPressed: busy ? () {} : _submitRestore,
|
|
child: busy ? const SmallLoadingSpinner() : const Text("Restore"),
|
|
),
|
|
const SizedBox(height: 5),
|
|
SecondaryButton(
|
|
text: "Back",
|
|
onPressed: busy ? () {} : () => setState(() => step = _Step.welcome),
|
|
),
|
|
const SizedBox(height: 40),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _register(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_header(
|
|
context,
|
|
recallIntent
|
|
? "Pin this server's public key to finish restoring your address."
|
|
: "Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel.",
|
|
),
|
|
const SizedBox(height: 30),
|
|
TextField(
|
|
controller: addressController,
|
|
decoration: InputDecoration(
|
|
labelText: "Address",
|
|
hintText: "you@example.org[:1961]",
|
|
filled: true,
|
|
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
|
),
|
|
),
|
|
const SizedBox(height: 15),
|
|
TextField(
|
|
controller: serverKeyController,
|
|
decoration: InputDecoration(
|
|
labelText: "Server public key",
|
|
hintText: "base32, 52 characters",
|
|
filled: true,
|
|
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
|
),
|
|
),
|
|
if (!recallIntent) ...[
|
|
const SizedBox(height: 15),
|
|
TextField(
|
|
controller: tokenController,
|
|
decoration: InputDecoration(
|
|
labelText: "Invite token (optional)",
|
|
filled: true,
|
|
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 40),
|
|
PrimaryButton(
|
|
onPressed: busy
|
|
? () {}
|
|
: (recallIntent ? _submitRecall : _submitRegistration),
|
|
child: busy
|
|
? const SmallLoadingSpinner()
|
|
: Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
|
|
),
|
|
// Restoring a master has no well-defined "register instead" fallback
|
|
// until its rotation index is resolved (§2), so that escape hatch is
|
|
// only offered from the fresh-identity path.
|
|
if (!restoreIntent)
|
|
TextButton(
|
|
onPressed: busy
|
|
? () {}
|
|
: (recallIntent ? _submitRegistration : _submitRecall),
|
|
child: Text(recallIntent
|
|
? "Register a new address instead"
|
|
: "Already registered? Recall"),
|
|
),
|
|
const SizedBox(height: 80),
|
|
],
|
|
);
|
|
}
|
|
}
|