feat: replace mail protocol with smolmail and rebuild the UI
This commit is contained in:
parent
cb24b68069
commit
e94e4158cc
93 changed files with 6387 additions and 3479 deletions
324
lib/presentation/screens/onboarding_screen.dart
Normal file
324
lib/presentation/screens/onboarding_screen.dart
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
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;
|
||||
|
||||
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<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: 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<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 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<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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Invite token (optional)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue