feat: replace mail protocol with smolmail and rebuild the UI

This commit is contained in:
randogoth 2026-09-26 22:45:38 +03:00
parent cb24b68069
commit e94e4158cc
93 changed files with 6387 additions and 3479 deletions

View file

@ -1,34 +0,0 @@
import "package:flutter/material.dart";
import "package:auto_route/auto_route.dart";
import "package:flash_mail/presentation/widgets/auth/account_buttons.dart";
@RoutePage()
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Image(
image: AssetImage("assets/images/logo-small.png"),
),
Text(
"FlashMail",
style: Theme.of(context).textTheme.titleLarge,
)
],
),
const AccountButtons()
],
),
);
}
}

View file

@ -0,0 +1,127 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/primary_button.dart";
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
/// One tap seals one envelope; the button disables while the send is in
/// flight so a second tap cannot seal a second message.
@RoutePage()
class ComposeScreen extends ConsumerStatefulWidget {
final String? to;
final String? subject;
const ComposeScreen({
super.key,
this.to,
this.subject,
});
@override
ConsumerState<ComposeScreen> createState() => _ComposeScreenState();
}
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
late final toController = TextEditingController(text: widget.to ?? "");
late final subjectController =
TextEditingController(text: widget.subject ?? "");
final bodyController = TextEditingController();
bool anonymous = false;
bool busy = false;
@override
void dispose() {
toController.dispose();
subjectController.dispose();
bodyController.dispose();
super.dispose();
}
Future<void> _send() async {
final client = ref.read(clientProvider);
setState(() => busy = true);
try {
final to = await client.send(
toController.text,
subjectController.text,
bodyController.text,
anonymous: anonymous,
);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("sent to $to")));
AutoRouter.of(context).pop();
}
} on SmolError catch (err) {
if (mounted) {
showErrorSnackBar(context, err.message);
}
} finally {
if (mounted) setState(() => busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Write")),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: toController,
decoration: InputDecoration(
labelText: "To",
hintText: "user@host[:1961] or smol://user@host/key",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 15),
TextField(
controller: subjectController,
decoration: InputDecoration(
labelText: "Subject",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 15),
TextField(
controller: bodyController,
maxLines: 12,
decoration: InputDecoration(
labelText: "Body",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
SwitchListTile(
value: anonymous,
onChanged: (value) => setState(() => anonymous = value),
title: Text(
"Anonymous (omit the signed Reply-To)",
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 10),
Center(
child: PrimaryButton(
onPressed: busy ? () {} : _send,
child: busy ? const SmallLoadingSpinner() : const Text("Send"),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,158 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
/// The full key, the address to hand out, and any key this one replaced —
/// the row can only afford a fingerprint, and §8 turns on the user being able
/// to inspect exactly what changed.
@RoutePage()
class ContactDetailScreen extends ConsumerStatefulWidget {
final String address;
const ContactDetailScreen({super.key, required this.address});
@override
ConsumerState<ContactDetailScreen> createState() =>
_ContactDetailScreenState();
}
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
bool resolving = false;
Future<void> _reResolve() async {
final client = ref.read(clientProvider);
setState(() => resolving = true);
try {
final outcome = await client.refreshContact(widget.address);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
// A rotation or an unexplained key change is §8 material; it stays
// until dismissed and stands out like any other warning.
if (outcome.warn) {
showErrorSnackBar(context, outcome.message,
duration: const Duration(days: 1));
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(outcome.message)),
);
}
}
} on SmolError catch (err) {
if (mounted) {
showErrorSnackBar(context, err.message);
}
} finally {
if (mounted) setState(() => resolving = false);
}
}
@override
Widget build(BuildContext context) {
final contact = ref.watch(storeProvider).contact(widget.address);
return Scaffold(
appBar: AppBar(title: Text(widget.address)),
body: contact == null
? Center(
child: Text("no such contact",
style: Theme.of(context).textTheme.labelSmall))
: ListView(
padding: const EdgeInsets.all(20),
children: [
_chip(context, contact.verified
? "verified key"
: "key pinned on first use"),
const SizedBox(height: 20),
_field(context, "fingerprint", fingerprint(contact.key)),
_field(context, "public key", b32encode(contact.key)),
Row(
children: [
Expanded(
child: _field(
context,
"address",
parseAddress(widget.address)
.uri(contact.key)),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () => copyText(
context,
parseAddress(widget.address).uri(contact.key)),
),
],
),
if (contact.history.isNotEmpty) ...[
const SizedBox(height: 30),
Text("previous keys (${contact.history.length})",
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 5),
Text(
"Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these "
"was sent before the change; rotation is not revocation, so a stolen key "
"can still rotate onward.",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 10),
for (final entry in contact.history)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(fingerprint(entry.key)),
Text(b32encode(entry.key),
style: Theme.of(context).textTheme.labelSmall),
Text(
"replaced ${formatTime(entry.until ~/ 1000)}",
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
const SizedBox(height: 30),
resolving
? const Center(child: CircularProgressIndicator())
: TextButton.icon(
onPressed: _reResolve,
icon: const Icon(Icons.refresh, size: 18),
label: const Text("Re-resolve"),
),
],
),
);
}
Widget _chip(BuildContext context, String text) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).primaryColor),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: Theme.of(context).primaryColor),
),
);
Widget _field(BuildContext context, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
SelectableText(value, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}

View file

@ -0,0 +1,130 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.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/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
/// Contacts: the addresses bound to keys, with the trust badge and the number
/// of keys each has replaced — the §8 surface for noticing rotations.
@RoutePage()
class ContactsScreen extends ConsumerWidget {
const ContactsScreen({super.key});
Future<void> _import(BuildContext context, WidgetRef ref) async {
final client = ref.read(clientProvider);
final controller = TextEditingController();
final text = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Import contact"),
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: "smol://user@host/key",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: const Text("Import"),
),
],
),
);
if (text == null || text.trim().isEmpty) return;
try {
client.importContact(text);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("contact imported (verified key)")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final contacts = ref.watch(contactsProvider);
return Scaffold(
appBar: AppBar(
title: const Text("Contacts"),
actions: [
IconButton(
tooltip: "Import smol:// address",
onPressed: () => _import(context, ref),
icon: const Icon(Icons.person_add),
),
],
),
body: contacts.isEmpty
? Center(
child: Text(
"no contacts yet — import a smol:// address, or write to someone",
style: Theme.of(context).textTheme.labelSmall,
textAlign: TextAlign.center,
),
)
: ListView.builder(
itemCount: contacts.length,
itemBuilder: (ctx, i) {
final (address, contact) = contacts[i];
return ListTile(
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
onTap: () => AutoRouter.of(context)
.push(ContactDetailRoute(address: address)),
title: Text(address),
subtitle: Text(
fingerprint(contact.key),
style: Theme.of(context).textTheme.labelSmall,
),
trailing: Wrap(
spacing: 6,
children: [
_chip(context, contact.verified ? "verified" : "tofu",
ok: contact.verified),
if (contact.history.isNotEmpty)
_chip(context, "${contact.history.length} previous",
ok: false),
],
),
);
},
),
);
}
Widget _chip(BuildContext context, String text, {required bool ok}) =>
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
border: Border.all(
color: ok
? Theme.of(context).primaryColor
: Theme.of(context).textTheme.labelSmall!.color!,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: Theme.of(context).textTheme.labelSmall!.copyWith(
color: ok
? Theme.of(context).primaryColor
: Theme.of(context).textTheme.labelSmall!.color,
),
),
);
}

View file

@ -1,62 +1,82 @@
import "dart:async";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/drawer/app_drawer.dart";
import "package:flash_mail/presentation/widgets/image_banner.dart";
import "package:flash_mail/presentation/widgets/message/message_list.dart";
import "package:flash_mail/presentation/widgets/shimmer/message_shimmer.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/drawer/app_drawer.dart";
import "package:smol_mail/presentation/widgets/image_banner.dart";
import "package:smol_mail/presentation/widgets/message/message_list.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
/// fetch, which pulls everything and acknowledges what verifies.
@RoutePage()
class InboxScreen extends ConsumerStatefulWidget {
class InboxScreen extends ConsumerWidget {
const InboxScreen({super.key});
@override
ConsumerState<InboxScreen> createState() => _InboxScreenState();
}
class _InboxScreenState extends ConsumerState<InboxScreen> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(
const Duration(seconds: 30),
(timer) => ref.refresh(messagesProvider),
);
Future<void> _fetch(BuildContext context, WidgetRef ref) async {
final client = ref.read(clientProvider);
try {
final summary = await client.fetch();
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
final tail = summary.rejected.isEmpty
? ""
: "\n${summary.rejected.length} left on the server";
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("${summary.stored} new$tail")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider);
final folder = ref.watch(folderProvider);
return Scaffold(
appBar: AppBar(
title: const Text("Inbox"),
title: Text(folder == "inbox" ? "Inbox" : "Sent"),
actions: [
if (folder == "inbox")
IconButton(
tooltip: "Fetch",
onPressed: () => _fetch(context, ref),
icon: const Icon(Icons.cloud_download),
),
],
),
drawer: const AppDrawer(),
body: messages.when(
data: (data) => MessageList(
messages: data,
onRefresh: () => ref.refresh(messagesProvider.future),
),
error: (err, _) => const ImageBanner(
imgSrc: "assets/images/error.png",
text: "Something went wrong!",
),
loading: () => const MessageShimmer(),
skipLoadingOnRefresh: true,
floatingActionButton: FloatingActionButton(
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
child: const Icon(Icons.edit),
),
body: messages.isEmpty
? RefreshIndicator(
onRefresh: () => folder == "inbox"
? _fetch(context, ref)
: Future.value(),
child: ListView(
children: const [
ImageBanner(
imgSrc: "assets/images/empty.png",
text: "Nothing here yet!",
),
],
),
)
: MessageList(
onRefresh: () => folder == "inbox"
? _fetch(context, ref)
: Future.value(),
),
);
}
}

View file

@ -1,31 +0,0 @@
import "package:flutter/material.dart";
import "package:auto_route/auto_route.dart";
import "package:flash_mail/presentation/widgets/auth/login_form.dart";
@RoutePage()
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(25),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
"Login",
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 25),
LoginForm(),
],
),
),
);
}
}

View file

@ -1,69 +1,128 @@
import "dart:typed_data";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/image_banner.dart";
import "package:flash_mail/presentation/widgets/message/message_view.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/image_banner.dart";
import "package:smol_mail/presentation/widgets/message/message_view.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
/// Fetched mail is acknowledged off the server during fetch, so deleting here
/// removes only the local sealed copy.
@RoutePage()
class MessageDetailScreen extends ConsumerWidget {
final String folder;
final String id;
final String subject;
const MessageDetailScreen({
super.key,
required this.folder,
required this.id,
required this.subject,
});
void deleteMessage(BuildContext context, WidgetRef ref) async {
await ref.read(clientProvider).deleteMessage(id);
ref.invalidate(messagesProvider);
Future<void> _delete(BuildContext context, WidgetRef ref) async {
await ref.read(storeProvider).deleteMessage(folder, id);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
AutoRouter.of(context).pop();
}
}
Future<void> _nameSender(BuildContext context, WidgetRef ref,
Uint8List senderKey) async {
final client = ref.read(clientProvider);
final controller = TextEditingController();
final address = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: "user@host or smol://…",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: const Text("Save"),
),
],
),
);
if (address == null || address.trim().isEmpty) return;
try {
await client.nameSender(address, senderKey);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Address saved for this sender")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final message = ref.watch(messageDetailProvider(id));
final record = ref.watch(storeProvider).getMessage(folder, id);
if (record == null) {
return Scaffold(
appBar: AppBar(),
body: const ImageBanner(
imgSrc: "assets/images/error.png",
text: "Message not found!",
),
);
}
final client = ref.read(clientProvider);
final opened = client.describe(record);
final isSent = folder == "sent";
final who = isSent
? (record.recipient ?? "")
: (opened.sender == null
? ""
: ref.watch(storeProvider).addressForKey(opened.sender!) ?? "");
return Scaffold(
appBar: AppBar(
title: who.isEmpty
? null
: Text(
who,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: "Inter",
fontWeight: FontWeight.w500,
fontSize: 18,
color: Theme.of(context).colorScheme.onSurface,
),
),
actions: [
IconButton(
onPressed: () => deleteMessage(context, ref),
icon: const Icon(Icons.delete, color: Colors.white),
)
tooltip: "Delete local copy",
onPressed: () => _delete(context, ref),
icon: const Icon(Icons.delete),
),
],
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Column(
children: [
Container(
alignment: Alignment.centerLeft,
child: Text(
subject,
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontSize: 20),
),
),
const SizedBox(height: 30),
message.when(
data: (data) => MessageView(message: data),
error: (err, _) => const ImageBanner(
imgSrc: "assets/images/error.png",
text: "Something went wrong!",
),
loading: () => const SizedBox.shrink(),
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: MessageView(
record: record,
opened: opened,
onNameSender: opened.sender == null
? null
: (key) => _nameSender(context, ref, key),
),
),
);

View 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),
],
);
}
}

View file

@ -0,0 +1,425 @@
import "dart:convert";
import "dart:io";
import "package:auto_route/auto_route.dart";
import "package:file_picker/file_picker.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:share_plus/share_plus.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
/// Identity card, server pins, rotation and the wipe. The seed is the only
/// secret; revealing it is always an explicit action.
@RoutePage()
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
bool seedShown = false;
final hostController = TextEditingController();
final keyController = TextEditingController();
final addressController = TextEditingController();
final tokenController = TextEditingController();
@override
void dispose() {
hostController.dispose();
keyController.dispose();
addressController.dispose();
tokenController.dispose();
super.dispose();
}
void _showError(Object error) {
if (!mounted) return;
showErrorSnackBar(context,
error is SmolError ? error.message : "Something went wrong!");
}
Future<void> _register() async {
final client = ref.read(clientProvider);
try {
await client.registerAccount(addressController.text,
token: tokenController.text.trim());
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("registered")),
);
}
} catch (err) {
_showError(err);
}
}
// Mail, once fetched, exists only on this device — the server deletes it as
// it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and
// pinned servers into one shareable file; never the seed.
Future<void> _export() async {
final data = ref.read(storeProvider).exportData();
final stamp = DateTime.now().toIso8601String().substring(0, 10);
final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json");
await file.writeAsString(jsonEncode(data));
await SharePlus.instance.share(ShareParams(
files: [XFile(file.path)], text: "kirakira backup $stamp"));
if (mounted) {
final messages = (data["inbox"] as List).length + (data["sent"] as List).length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("exported $messages messages, "
"${(data["contacts"] as Map).length} contacts, "
"${(data["servers"] as Map).length} server keys")),
);
}
}
Future<void> _import() async {
final files = await FilePicker.pickFiles(
type: FileType.custom, allowedExtensions: ["json"]);
final path = files.isEmpty ? null : files.single.path;
if (path == null) return;
try {
final data = jsonDecode(File(path).readAsStringSync()) as Map;
final summary = await ref.read(storeProvider).importData(data);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("imported: $summary")),
);
}
} on Exception catch (err) {
_showError(err);
} on TypeError catch (err) {
_showError(err);
}
}
// Bind an address this seed was already registered under, without REGISTER.
Future<void> _recall() async {
final client = ref.read(clientProvider);
try {
final addr = await client.recallAccount(addressController.text);
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("recalled ${addr.short}")),
);
}
} catch (err) {
_showError(err);
}
}
Future<void> _rotate() async {
final client = ref.read(clientProvider);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Rotate the identity key?"),
content: const Text(
"Contacts accept the change from the signed chain. The old key is kept, "
"since mail sealed to it stays readable with nothing else."),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Cancel")),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Rotate")),
],
),
);
if (confirmed != true) return;
try {
final fresh = await client.rotateIdentity();
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("rotated; new key ${b32encode(fresh.publicKey)}")),
);
}
} catch (err) {
_showError(err);
}
}
Future<void> _wipe() async {
final client = ref.read(clientProvider);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Wipe everything?"),
content: const Text(
"Deletes the identity, every pinned key and every stored message from "
"this device. Mail left on servers stays there."),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text("Cancel")),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text("Wipe")),
],
),
);
if (confirmed != true) return;
await client.wipe();
ref.read(revisionProvider.notifier).bump();
if (mounted) {
AutoRouter.of(context).replace(OnboardingRoute());
}
}
@override
Widget build(BuildContext context) {
final store = ref.watch(storeProvider);
final me = ref.watch(identityProvider);
final account = ref.watch(accountProvider);
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
final seedHex = hex(me.seed);
final share = account?.uri(me.publicKey);
final pins = store.allPins();
return Scaffold(
appBar: AppBar(title: const Text("Settings")),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
_card(
context,
children: [
Text("fingerprint", style: Theme.of(context).textTheme.labelSmall),
Text(fingerprint(me.publicKey)),
const SizedBox(height: 15),
Text("address", style: Theme.of(context).textTheme.labelSmall),
Text(account?.short ?? "(not registered)"),
const SizedBox(height: 15),
Text("share", style: Theme.of(context).textTheme.labelSmall),
Row(
children: [
Expanded(
child: Text(
share ?? "(register to get a shareable address)",
overflow: TextOverflow.ellipsis,
),
),
if (share != null)
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () =>
Clipboard.setData(ClipboardData(text: share)),
),
],
),
],
),
const SizedBox(height: 20),
_card(
context,
borderColor: Theme.of(context).colorScheme.error,
children: [
Text("seed", style: Theme.of(context).textTheme.labelSmall),
Row(
children: [
Expanded(
child: SelectableText(
seedShown ? seedHex : "•" * 64,
style: Theme.of(context).textTheme.bodySmall,
),
),
TextButton(
onPressed: () =>
setState(() => seedShown = !seedShown),
child: Text(seedShown ? "hide" : "reveal"),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () =>
Clipboard.setData(ClipboardData(text: seedHex)),
),
],
),
Text(
"back this seed up; it is the only secret",
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 10),
Text(
"retired keys: ${store.identities().length - 1} (kept to read old mail)",
style: Theme.of(context).textTheme.labelSmall),
],
),
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 20),
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
for (final (host, key) in pins)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(host),
subtitle: Text(
b32encode(key),
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () {
store.unpinServer(host);
ref.read(revisionProvider.notifier).bump();
setState(() {});
},
),
),
if (pins.isEmpty)
Text("no pinned servers", style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 10),
TextField(
controller: hostController,
decoration: InputDecoration(
labelText: "Host",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextField(
controller: keyController,
decoration: InputDecoration(
labelText: "Server public key (base32)",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextButton(
onPressed: () {
try {
ref
.read(clientProvider)
.pinServer(hostController.text, keyController.text);
keyController.clear();
setState(() {});
} on SmolError catch (err) {
_showError(err);
} catch (err) {
_showError(err);
}
},
child: const Text("Pin server"),
),
if (account == null) ...[
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 20),
Text("register an address",
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
TextField(
controller: addressController,
decoration: InputDecoration(
labelText: "Address",
hintText: "you@example.org[:1961]",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextField(
controller: tokenController,
decoration: InputDecoration(
labelText: "Invite token (optional)",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
),
),
const SizedBox(height: 10),
TextButton(
onPressed: _register,
child: const Text("Register"),
),
TextButton(
onPressed: _recall,
child: const Text("Already registered? Recall"),
),
],
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 20),
Text("backup", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 5),
Text(
"Mail, once fetched, exists only on this device. Export backs up the "
"inbox, sent mail, contacts and server pins — never the seed.",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 10),
Row(
children: [
TextButton.icon(
onPressed: _export,
icon: const Icon(Icons.share, size: 18),
label: const Text("Export"),
),
TextButton.icon(
onPressed: _import,
icon: const Icon(Icons.file_upload, size: 18),
label: const Text("Import"),
),
],
),
const SizedBox(height: 30),
const Divider(height: 1),
const SizedBox(height: 10),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text("Rotate identity key"),
trailing: const Icon(Icons.rotate_right),
onTap: _rotate,
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Wipe identity and messages",
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
trailing: Icon(Icons.delete_forever,
color: Theme.of(context).colorScheme.error),
onTap: _wipe,
),
],
),
);
}
Widget _card(BuildContext context,
{required List<Widget> children, Color? borderColor}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).extension<AppColors>()!.cardFill,
borderRadius: BorderRadius.circular(10),
border: borderColor == null ? null : Border.all(color: borderColor),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
}
}