kirakira/lib/presentation/screens/onboarding_screen.dart

393 lines
13 KiB
Dart
Raw Normal View History

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? 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<void> _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<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) {
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<AppColors>()!.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<AppColors>()!.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<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"),
),
TextButton(
onPressed: busy
? () {}
: (recallIntent ? _submitRegistration : _submitRecall),
child: Text(recallIntent
? "Register a new address instead"
: "Already registered? Recall"),
),
const SizedBox(height: 80),
],
);
}
}