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? createdSeed; 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; 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 seed still sitting in the store here can only be // an abandoned attempt from earlier in this same flow (wrong seed, failed // recall, "Back") — safe to replace rather than reject. // wipe() is fire-and-forget here, like every other store write in this // screen (setIdentity, 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 fresh = client.createIdentity(); setState(() { createdSeed = fresh.seed; step = _Step.backup; }); } on Exception catch (err) { _showError(err); } } void _restoreIdentity() { setState(() => step = _Step.restore); } // Restoring must succeed on its own even if recall fails (server not // pinned yet, offline, typo) — recall can always be retried from the // register step or settings afterward. void _submitRestore() { _clearAbandonedIdentity(); final client = ref.read(clientProvider); try { client.restoreIdentity(seedController.text); } on Exception catch (err) { _showError(err); return; } final addressText = restoreAddressController.text.trim(); if (addressText.isEmpty) { setState(() => step = _Step.register); return; } // 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 SmolAddress addr; try { addr = parseAddress(addressText); } on Exception catch (err) { // A genuine typo, distinct from the merely-unpinned case below — still // worth an error, but land on the same recall-oriented step to fix it. _showError(err); setState(() { recallIntent = true; step = _Step.register; }); return; } // Recall needs the host pinned first (SPEC.md §4). That's the expected, // common state right after a restore — not an error — so check for it // up front instead of letting recallAccount fail and surfacing that as // one: this address just needs a key before its first recall can proceed. if (ref.read(storeProvider).serverPin(addr.host) == null) { setState(() { recallIntent = true; step = _Step.register; }); return; } () async { try { await client.recallAccount(addressText); if (!mounted) return; ref.read(revisionProvider.notifier).bump(); AutoRouter.of(context).replace(InboxRoute()); } on Exception catch (err) { if (!mounted) return; _showError(err); setState(() { recallIntent = true; step = _Step.register; }); } }(); } Future _submitRegistration() async { await _submit(recall: false); } // Restoring a seed on a new device knows the identity but not the address it // was registered under; recall binds it 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) { 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(InboxRoute()); } } 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) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 60), Row( children: [ const Image(image: AssetImage("assets/images/logo-small.png")), const SizedBox(width: 10), 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 seed = createdSeed!; final seedHex = hex(seed); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _header(context, "Back up this seed; it is the only secret."), const SizedBox(height: 20), Text( "fingerprint:\n${fingerprint(ed25519PublicKey(seed))}", style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 20), SelectableText( seedHex, style: Theme.of(context) .textTheme .bodySmall! .copyWith(color: Theme.of(context).colorScheme.primary), ), TextButton( onPressed: () async { await Clipboard.setData(ClipboardData(text: seedHex)); if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( const SnackBar(content: Text("Seed copied to clipboard")), ); } }, child: const Text("Copy seed"), ), 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 seed 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 (if already registered)", hintText: "alice@example.org", filled: true, fillColor: Theme.of(context).extension()!.cardFill, ), ), const SizedBox(height: 30), PrimaryButton( onPressed: _submitRestore, child: const Text("Restore"), ), const SizedBox(height: 5), SecondaryButton( text: "Back", onPressed: () => 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"), ), TextButton( onPressed: busy ? () {} : (recallIntent ? _submitRegistration : _submitRecall), child: Text(recallIntent ? "Register a new address instead" : "Already registered? Recall"), ), const SizedBox(height: 80), ], ); } }