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 createState() => _OnboardingScreenState(); } enum _Step { welcome, backup, restore, register } class _OnboardingScreenState extends ConsumerState { _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 _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 _submitRegistration() async { await _submit(recall: false); } // Recall binds a restored or already-created identity to its registered // address without re-REGISTER. Future _submitRecall() async { await _submit(recall: true); } Future _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()!.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()!.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()!.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()!.cardFill, ), ), if (!recallIntent) ...[ const SizedBox(height: 15), TextField( controller: tokenController, decoration: InputDecoration( labelText: "Invite token (optional)", filled: true, fillColor: Theme.of(context).extension()!.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), ], ); } }