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; 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()); } void _createIdentity() { final client = ref.read(clientProvider); final fresh = client.createIdentity(); setState(() { createdSeed = fresh.seed; step = _Step.backup; }); } 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() { 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; } () 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(() => 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: Padding( 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 Spacer(), 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 Spacer(), 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 Spacer(), ], ); } Widget _register(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _header(context, "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, ), ), const SizedBox(height: 15), TextField( controller: tokenController, decoration: InputDecoration( labelText: "Invite token (optional)", filled: true, fillColor: Theme.of(context).extension()!.cardFill, ), ), const Spacer(), PrimaryButton( onPressed: busy ? () {} : _submitRegistration, child: busy ? const SmallLoadingSpinner() : const Text("Pin and Register"), ), TextButton( onPressed: busy ? () {} : _submitRecall, child: const Text("Already registered? Recall"), ), const SizedBox(height: 80), ], ); } }