feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention

This commit is contained in:
randogoth 2026-09-27 11:07:11 +03:00
parent eaaa3f2ede
commit c693e6fcb9
27 changed files with 1190 additions and 592 deletions

View file

@ -20,9 +20,14 @@ class AppRouter extends RootStackRouter {
guards: [HomeGuard(ref)],
),
AutoRoute(
page: InboxRoute.page,
path: "/inbox",
page: HomeRoute.page,
path: "/home",
guards: [IdentityGuard(ref)],
children: [
AutoRoute(page: InboxRoute.page, path: "inbox", initial: true),
AutoRoute(page: ContactsRoute.page, path: "contacts"),
AutoRoute(page: SettingsRoute.page, path: "settings"),
],
),
AutoRoute(
page: MessageDetailRoute.page,
@ -34,20 +39,10 @@ class AppRouter extends RootStackRouter {
path: "/compose",
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: ContactsRoute.page,
path: "/contacts",
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: ContactDetailRoute.page,
path: "/contact",
guards: [IdentityGuard(ref)],
),
AutoRoute(
page: SettingsRoute.page,
path: "/settings",
guards: [IdentityGuard(ref)],
),
];
}

View file

@ -10,26 +10,27 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i8;
import 'package:flutter/material.dart' as _i9;
import 'package:auto_route/auto_route.dart' as _i9;
import 'package:flutter/material.dart' as _i10;
import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1;
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
as _i2;
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i4;
import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5;
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
as _i5;
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6;
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7;
as _i6;
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7;
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8;
/// generated route for
/// [_i1.ComposeScreen]
class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
ComposeRoute({
_i9.Key? key,
_i10.Key? key,
String? to,
String? subject,
List<_i8.PageRouteInfo>? children,
List<_i9.PageRouteInfo>? children,
}) : super(
ComposeRoute.name,
args: ComposeRouteArgs(key: key, to: to, subject: subject),
@ -38,7 +39,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
static const String name = 'ComposeRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ComposeRouteArgs>(
@ -56,7 +57,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
class ComposeRouteArgs {
const ComposeRouteArgs({this.key, this.to, this.subject});
final _i9.Key? key;
final _i10.Key? key;
final String? to;
@ -80,11 +81,11 @@ class ComposeRouteArgs {
/// generated route for
/// [_i2.ContactDetailScreen]
class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
ContactDetailRoute({
_i9.Key? key,
_i10.Key? key,
required String address,
List<_i8.PageRouteInfo>? children,
List<_i9.PageRouteInfo>? children,
}) : super(
ContactDetailRoute.name,
args: ContactDetailRouteArgs(key: key, address: address),
@ -93,7 +94,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
static const String name = 'ContactDetailRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ContactDetailRouteArgs>();
@ -105,7 +106,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
class ContactDetailRouteArgs {
const ContactDetailRouteArgs({this.key, required this.address});
final _i9.Key? key;
final _i10.Key? key;
final String address;
@ -127,13 +128,13 @@ class ContactDetailRouteArgs {
/// generated route for
/// [_i3.ContactsScreen]
class ContactsRoute extends _i8.PageRouteInfo<void> {
const ContactsRoute({List<_i8.PageRouteInfo>? children})
class ContactsRoute extends _i9.PageRouteInfo<void> {
const ContactsRoute({List<_i9.PageRouteInfo>? children})
: super(ContactsRoute.name, initialChildren: children);
static const String name = 'ContactsRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
return const _i3.ContactsScreen();
@ -142,29 +143,45 @@ class ContactsRoute extends _i8.PageRouteInfo<void> {
}
/// generated route for
/// [_i4.InboxScreen]
class InboxRoute extends _i8.PageRouteInfo<void> {
const InboxRoute({List<_i8.PageRouteInfo>? children})
: super(InboxRoute.name, initialChildren: children);
/// [_i4.HomeScreen]
class HomeRoute extends _i9.PageRouteInfo<void> {
const HomeRoute({List<_i9.PageRouteInfo>? children})
: super(HomeRoute.name, initialChildren: children);
static const String name = 'InboxRoute';
static const String name = 'HomeRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
return const _i4.InboxScreen();
return const _i4.HomeScreen();
},
);
}
/// generated route for
/// [_i5.MessageDetailScreen]
class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
/// [_i5.InboxScreen]
class InboxRoute extends _i9.PageRouteInfo<void> {
const InboxRoute({List<_i9.PageRouteInfo>? children})
: super(InboxRoute.name, initialChildren: children);
static const String name = 'InboxRoute';
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
return const _i5.InboxScreen();
},
);
}
/// generated route for
/// [_i6.MessageDetailScreen]
class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
MessageDetailRoute({
_i9.Key? key,
_i10.Key? key,
required String folder,
required String id,
List<_i8.PageRouteInfo>? children,
List<_i9.PageRouteInfo>? children,
}) : super(
MessageDetailRoute.name,
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
@ -173,11 +190,11 @@ class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
static const String name = 'MessageDetailRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
final args = data.argsAs<MessageDetailRouteArgs>();
return _i5.MessageDetailScreen(
return _i6.MessageDetailScreen(
key: args.key,
folder: args.folder,
id: args.id,
@ -193,7 +210,7 @@ class MessageDetailRouteArgs {
required this.id,
});
final _i9.Key? key;
final _i10.Key? key;
final String folder;
@ -216,33 +233,33 @@ class MessageDetailRouteArgs {
}
/// generated route for
/// [_i6.OnboardingScreen]
class OnboardingRoute extends _i8.PageRouteInfo<void> {
const OnboardingRoute({List<_i8.PageRouteInfo>? children})
/// [_i7.OnboardingScreen]
class OnboardingRoute extends _i9.PageRouteInfo<void> {
const OnboardingRoute({List<_i9.PageRouteInfo>? children})
: super(OnboardingRoute.name, initialChildren: children);
static const String name = 'OnboardingRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
return const _i6.OnboardingScreen();
return const _i7.OnboardingScreen();
},
);
}
/// generated route for
/// [_i7.SettingsScreen]
class SettingsRoute extends _i8.PageRouteInfo<void> {
const SettingsRoute({List<_i8.PageRouteInfo>? children})
/// [_i8.SettingsScreen]
class SettingsRoute extends _i9.PageRouteInfo<void> {
const SettingsRoute({List<_i9.PageRouteInfo>? children})
: super(SettingsRoute.name, initialChildren: children);
static const String name = 'SettingsRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
return const _i7.SettingsScreen();
return const _i8.SettingsScreen();
},
);
}

View file

@ -15,7 +15,7 @@ class HomeGuard extends AutoRouteGuard {
void onNavigation(NavigationResolver resolver, StackRouter router) {
if (ref.read(identityProvider) != null &&
ref.read(accountProvider) != null) {
router.replace(InboxRoute());
router.replace(HomeRoute());
} else {
resolver.next(true);
}

View file

@ -24,6 +24,34 @@ class ContactDetailScreen extends ConsumerStatefulWidget {
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
bool resolving = false;
bool tokenBusy = false;
// §5.8: admitting or withdrawing a contact's accept token pushes the
// change to the server right away, since it only takes effect once the
// server holds the changed set.
Future<void> _toggleAccepted(bool currentlyAccepted) async {
final client = ref.read(clientProvider);
setState(() => tokenBusy = true);
try {
if (currentlyAccepted) {
await client.blockContact(widget.address);
} else {
await client.acceptContact(widget.address);
}
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(currentlyAccepted
? "${widget.address} blocked; their mail now lands in requests"
: "${widget.address} accepted; their mail now lands in your main tier")),
);
}
} on SmolError catch (err) {
if (mounted) showErrorSnackBar(context, err.message);
} finally {
if (mounted) setState(() => tokenBusy = false);
}
}
Future<void> _reResolve() async {
final client = ref.read(clientProvider);
@ -54,7 +82,11 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
@override
Widget build(BuildContext context) {
final contact = ref.watch(storeProvider).contact(widget.address);
ref.watch(revisionProvider);
final store = ref.read(storeProvider);
final contact = store.contact(widget.address);
final accepted = store.accepted(widget.address);
final isAccepted = accepted != null && accepted.active;
return Scaffold(
appBar: AppBar(title: Text(widget.address)),
@ -69,6 +101,29 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
? "verified key"
: "key pinned on first use"),
const SizedBox(height: 20),
Text(
"Accept token (SPEC.md §5.8)",
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 5),
Text(
isAccepted
? "This contact's mail lands in your main tier."
: "This contact's mail lands in requests until accepted.",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 10),
tokenBusy
? const Center(child: CircularProgressIndicator())
: TextButton.icon(
onPressed: () => _toggleAccepted(isAccepted),
icon: Icon(isAccepted ? Icons.block : Icons.check_circle_outline,
size: 18),
label: Text(isAccepted ? "Block" : "Accept"),
),
const SizedBox(height: 20),
const Divider(height: 1),
const SizedBox(height: 20),
_field(context, "fingerprint", fingerprint(contact.key)),
_field(context, "public key", b32encode(contact.key)),
Row(

View file

@ -82,6 +82,7 @@ class ContactsScreen extends ConsumerWidget {
itemCount: contacts.length,
itemBuilder: (ctx, i) {
final (address, contact) = contacts[i];
final accepted = ref.read(storeProvider).accepted(address);
return ListTile(
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
onTap: () => AutoRouter.of(context)
@ -93,12 +94,15 @@ class ContactsScreen extends ConsumerWidget {
),
trailing: Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_chip(context, contact.verified ? "verified" : "tofu",
ok: contact.verified),
if (contact.history.isNotEmpty)
_chip(context, "${contact.history.length} previous",
ok: false),
if (accepted != null && accepted.active)
_chip(context, "accepted", ok: true),
],
),
);

View file

@ -0,0 +1,86 @@
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/presentation/theme/breakpoints.dart";
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
class _Destination {
final Widget icon;
final String label;
const _Destination({required this.icon, required this.label});
}
/// The app's only persistent navigation surface: a bottom [NavigationBar] on
/// phone-width screens, a side [NavigationRail] on desktop-width ones — no
/// hamburger drawer at either width. Mail/Contacts/Settings are switched by
/// tapping only, never by swipe, so a horizontal gesture inside a
/// destination (e.g. the Inbox/Sent/Requests tab bar) never changes section.
@RoutePage()
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final unread = ref.watch(unreadProvider) + ref.watch(requestsUnreadProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
final mailIcon = Icon(FlashMailIcons.inbox);
final destinations = [
_Destination(
icon: unread > 0 ? Badge(label: Text("$unread"), child: mailIcon) : mailIcon,
label: "Mail",
),
const _Destination(icon: Icon(Icons.contacts), label: "Contacts"),
const _Destination(icon: Icon(Icons.settings), label: "Settings"),
];
return AutoTabsRouter.builder(
routes: const [InboxRoute(), ContactsRoute(), SettingsRoute()],
builder: (context, children, tabsRouter) {
final body = IndexedStack(index: tabsRouter.activeIndex, children: children);
final wide = MediaQuery.sizeOf(context).width >= kDesktopBreakpoint;
if (!wide) {
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: tabsRouter.setActiveIndex,
indicatorColor: appColors.highlight,
destinations: [
for (final d in destinations)
NavigationDestination(icon: d.icon, label: d.label),
],
),
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: tabsRouter.setActiveIndex,
backgroundColor: appColors.cardFill,
useIndicator: true,
indicatorColor: appColors.highlight,
labelType: NavigationRailLabelType.all,
destinations: [
for (final d in destinations)
NavigationRailDestination(icon: d.icon, label: Text(d.label)),
],
),
const VerticalDivider(width: 1),
Expanded(child: body),
],
),
);
},
);
}
}

View file

@ -4,18 +4,46 @@ 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/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";
const _folders = ["inbox", "requests", "sent"];
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
/// fetch, which pulls everything and acknowledges what verifies.
/// fetch, which pulls everything and acknowledges what verifies. One FETCH
/// call populates both Inbox and Requests (they're tiers of the same
/// mailbox), so both — but not the purely local Sent folder — offer fetch.
@RoutePage()
class InboxScreen extends ConsumerWidget {
class InboxScreen extends ConsumerStatefulWidget {
const InboxScreen({super.key});
@override
ConsumerState<InboxScreen> createState() => _InboxScreenState();
}
class _InboxScreenState extends ConsumerState<InboxScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: _folders.length, vsync: this)
..addListener(() {
// Fires once settled, whether the change came from a tap or a swipe;
// only the AppBar's fetch button depends on which tab is active.
if (!_tabController.indexIsChanging) setState(() {});
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _fetch(BuildContext context, WidgetRef ref) async {
final client = ref.read(clientProvider);
try {
@ -36,47 +64,63 @@ class InboxScreen extends ConsumerWidget {
}
}
Widget _folderView(BuildContext context, WidgetRef ref, String folder) {
final messages = ref.watch(messagesForFolderProvider(folder));
final canFetch = folder != "sent";
final onRefresh = canFetch ? () => _fetch(context, ref) : () => Future.value();
if (messages.isEmpty) {
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
children: const [
ImageBanner(
imgSrc: "assets/images/empty.png",
text: "Nothing here yet!",
),
],
),
);
}
return MessageList(folder: folder, onRefresh: onRefresh);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider);
final folder = ref.watch(folderProvider);
Widget build(BuildContext context) {
final unread = ref.watch(unreadProvider);
final requestsUnread = ref.watch(requestsUnreadProvider);
final canFetch = _folders[_tabController.index] != "sent";
return Scaffold(
appBar: AppBar(
title: Text(folder == "inbox" ? "Inbox" : "Sent"),
title: const Text("kirakira"),
actions: [
if (folder == "inbox")
if (canFetch)
IconButton(
tooltip: "Fetch",
onPressed: () => _fetch(context, ref),
icon: const Icon(Icons.cloud_download),
),
],
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(text: unread > 0 ? "Inbox ($unread)" : "Inbox"),
Tab(text: requestsUnread > 0 ? "Requests ($requestsUnread)" : "Requests"),
const Tab(text: "Sent"),
],
),
),
drawer: const AppDrawer(),
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(),
),
body: TabBarView(
controller: _tabController,
children: [
for (final folder in _folders) _folderView(context, ref, folder),
],
),
);
}
}

View file

@ -10,9 +10,10 @@ 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";
import "package:smol_mail/smol/store.dart";
/// Fetched mail is acknowledged off the server during fetch, so deleting here
/// removes only the local sealed copy.
/// Deleting removes the local copy, and the server's too if "leave mail on
/// server" left one there to remove (settings; SPEC.md §10).
@RoutePage()
class MessageDetailScreen extends ConsumerWidget {
final String folder;
@ -24,8 +25,14 @@ class MessageDetailScreen extends ConsumerWidget {
required this.id,
});
Future<void> _delete(BuildContext context, WidgetRef ref) async {
await ref.read(storeProvider).deleteMessage(folder, id);
Future<void> _delete(
BuildContext context, WidgetRef ref, MailRecord record) async {
try {
await ref.read(clientProvider).deleteMessage(folder, record);
} on SmolError catch (err) {
if (context.mounted) showErrorSnackBar(context, err.message);
return; // keep the local copy if a needed server delete failed
}
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
AutoRouter.of(context).pop();
@ -109,8 +116,8 @@ class MessageDetailScreen extends ConsumerWidget {
),
actions: [
IconButton(
tooltip: "Delete local copy",
onPressed: () => _delete(context, ref),
tooltip: "Delete",
onPressed: () => _delete(context, ref, record),
icon: const Icon(Icons.delete),
),
],
@ -118,6 +125,7 @@ class MessageDetailScreen extends ConsumerWidget {
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: MessageView(
folder: folder,
record: record,
opened: opened,
onNameSender: opened.sender == null

View file

@ -29,12 +29,17 @@ enum _Step { welcome, backup, restore, register }
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
_Step step = _Step.welcome;
Uint8List? createdSeed;
Uint8List? createdMaster;
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;
// Set alongside recallIntent when we got here from Restore rather than from
// "Already registered? Recall": there is no well-defined "register a new
// address instead" fallback for a restored master until its rotation index
// is known, so that escape hatch is hidden in this case (§2).
bool restoreIntent = false;
final seedController = TextEditingController();
final restoreAddressController = TextEditingController();
@ -62,11 +67,11 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
}
// 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
// identity+account, so a master still sitting in the store here can only be
// an abandoned attempt from earlier in this same flow (wrong master, 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
// screen (setMaster, 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() {
@ -80,9 +85,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
_clearAbandonedIdentity();
final client = ref.read(clientProvider);
try {
final fresh = client.createIdentity();
final master = client.createIdentity();
setState(() {
createdSeed = fresh.seed;
createdMaster = master;
step = _Step.backup;
});
} on Exception catch (err) {
@ -94,74 +99,49 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
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;
}
// §2: a master alone does not say which rotation index a server bound, so
// restoring resolves the address and walks indices to find it — restore
// always ends in a recall, never a bare local step.
Future<void> _submitRestore() async {
final addressText = restoreAddressController.text.trim();
if (addressText.isEmpty) {
setState(() => step = _Step.register);
_showError(const SmolError("enter the address this master was registered under"));
return;
}
_clearAbandonedIdentity();
// 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;
final client = ref.read(clientProvider);
setState(() => busy = true);
try {
addr = parseAddress(addressText);
await client.restoreAndRecall(seedController.text, addressText);
if (!mounted) return;
ref.read(revisionProvider.notifier).bump();
AutoRouter.of(context).replace(HomeRoute());
} 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.
if (!mounted) return;
// Most often the host just isn't pinned yet (SPEC.md §4) — the expected
// state right after a restore, not a dead end — so land on the
// recall-framed register step to collect a key and retry.
_showError(err);
setState(() {
recallIntent = true;
restoreIntent = true;
step = _Step.register;
});
return;
} finally {
if (mounted) setState(() => busy = false);
}
// 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.
// Recall binds a restored or already-created identity to its registered
// address without re-REGISTER.
Future<void> _submitRecall() async {
await _submit(recall: true);
}
@ -178,7 +158,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
if (serverKey.isNotEmpty) {
client.pinServer(address.host, serverKey);
}
if (recall) {
if (recall && restoreIntent) {
await client.restoreAndRecall(seedController.text, address.short);
} else if (recall) {
await client.recallAccount(address.short);
} else {
await client.registerAccount(address.short,
@ -186,7 +168,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
}
if (mounted) {
ref.read(revisionProvider.notifier).bump();
AutoRouter.of(context).replace(InboxRoute());
AutoRouter.of(context).replace(HomeRoute());
}
} catch (err) {
_showError(err);
@ -248,20 +230,21 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
}
Widget _backup(BuildContext context) {
final seed = createdSeed!;
final seedHex = hex(seed);
final master = createdMaster!;
final masterHex = hex(master);
final publicKey = ref.read(clientProvider).identity!.publicKey;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(context, "Back up this seed; it is the only secret."),
_header(context, "Back up this master secret; it is the only secret."),
const SizedBox(height: 20),
Text(
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}",
"fingerprint:\n${fingerprint(publicKey)}",
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 20),
SelectableText(
seedHex,
masterHex,
style: Theme.of(context)
.textTheme
.bodySmall!
@ -269,14 +252,14 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
),
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: seedHex));
await Clipboard.setData(ClipboardData(text: masterHex));
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text("Seed copied to clipboard")),
const SnackBar(content: Text("Master secret copied to clipboard")),
);
}
},
child: const Text("Copy seed"),
child: const Text("Copy master secret"),
),
const SizedBox(height: 40),
PrimaryButton(
@ -292,7 +275,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_header(context, "Enter the 32-byte seed as 64 hex characters."),
_header(context, "Enter the 32-byte master secret as 64 hex characters."),
const SizedBox(height: 20),
TextField(
controller: seedController,
@ -306,7 +289,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
TextField(
controller: restoreAddressController,
decoration: InputDecoration(
labelText: "Address (if already registered)",
labelText: "Address this master was registered under",
hintText: "alice@example.org",
filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
@ -314,13 +297,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
),
const SizedBox(height: 30),
PrimaryButton(
onPressed: _submitRestore,
child: const Text("Restore"),
onPressed: busy ? () {} : _submitRestore,
child: busy ? const SmallLoadingSpinner() : const Text("Restore"),
),
const SizedBox(height: 5),
SecondaryButton(
text: "Back",
onPressed: () => setState(() => step = _Step.welcome),
onPressed: busy ? () {} : () => setState(() => step = _Step.welcome),
),
const SizedBox(height: 40),
],
@ -377,14 +360,18 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
? 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"),
),
// Restoring a master has no well-defined "register instead" fallback
// until its rotation index is resolved (§2), so that escape hatch is
// only offered from the fresh-identity path.
if (!restoreIntent)
TextButton(
onPressed: busy
? () {}
: (recallIntent ? _submitRegistration : _submitRecall),
child: Text(recallIntent
? "Register a new address instead"
: "Already registered? Recall"),
),
const SizedBox(height: 80),
],
);

View file

@ -88,8 +88,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
if (mounted) {
// The export payload is sealed now (v2), so the counts for this
// notice come straight from the store rather than the ciphertext.
final messages =
store.listMessages("inbox").length + store.listMessages("sent").length;
final messages = store.listMessages("inbox").length +
store.listMessages("requests").length +
store.listMessages("sent").length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("exported $messages messages, "
"${store.allContacts().length} contacts, "
@ -207,7 +208,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final account = ref.watch(accountProvider);
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
final seedHex = hex(me.seed);
final masterHex = hex(store.master()!);
final share = account?.uri(me.publicKey);
final pins = store.allPins();
@ -249,12 +250,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
context,
borderColor: Theme.of(context).colorScheme.error,
children: [
Text("seed", style: Theme.of(context).textTheme.labelSmall),
Text("master secret", style: Theme.of(context).textTheme.labelSmall),
Row(
children: [
Expanded(
child: SelectableText(
seedShown ? seedHex : "•" * 64,
seedShown ? masterHex : "•" * 64,
style: Theme.of(context).textTheme.bodySmall,
),
),
@ -266,12 +267,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () =>
Clipboard.setData(ClipboardData(text: seedHex)),
Clipboard.setData(ClipboardData(text: masterHex)),
),
],
),
Text(
"back this seed up; it is the only secret",
"back this master secret up; it is the only secret",
style: Theme.of(context)
.textTheme
.labelSmall!
@ -279,12 +280,31 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
const SizedBox(height: 10),
Text(
"retired keys: ${store.identities().length - 1} (kept to read old mail)",
"rotations: ${store.rotations()} (superseded keys kept to read old mail)",
style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 4),
Text(
"accepted correspondents: ${store.allAccepted().where((e) => e.$2.active).length}"
"${store.syncOk() ? "" : " (not yet pushed to the server — accept them again)"}",
style: Theme.of(context).textTheme.labelSmall),
],
),
const SizedBox(height: 30),
const Divider(height: 1),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text("Leave mail on server"),
subtitle: const Text(
"Fetch keeps a copy on the server instead of deleting it. "
"Delete a message here to remove it from both."),
value: store.leaveOnServer(),
onChanged: (value) {
store.setLeaveOnServer(value);
setState(() {});
},
),
const SizedBox(height: 10),
const Divider(height: 1),
const SizedBox(height: 20),
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),

View file

@ -0,0 +1,4 @@
/// Below this logical width the home shell shows a bottom [NavigationBar]
/// (phone); at or above it, a side [NavigationRail] (desktop). Matches
/// Material 3's compact/medium width class boundary.
const double kDesktopBreakpoint = 600;

View file

@ -1,35 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter/material.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/presentation/widgets/drawer/identity_header.dart";
import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
class AppDrawer extends ConsumerWidget {
const AppDrawer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final identity = ref.watch(identityProvider);
final account = ref.watch(accountProvider);
final address = account?.short ?? "";
final fp = identity == null ? "" : fingerprint(identity.publicKey);
return Drawer(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
IdentityHeader(address: address, fingerprint: fp),
const SizedBox(height: 50),
const DrawerList(),
],
),
),
);
}
}

View file

@ -1,63 +0,0 @@
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:auto_route/auto_route.dart";
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
import "package:smol_mail/presentation/widgets/drawer/drawer_list_tile.dart";
class DrawerList extends ConsumerWidget {
const DrawerList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final unread = ref.watch(unreadProvider);
return Padding(
padding: const EdgeInsets.only(left: 25),
child: Column(
children: <Widget>[
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: FlashMailIcons.inbox,
title: "Inbox${unread > 0 ? " ($unread)" : ""}",
onTap: () {
ref.read(folderProvider.notifier).select("inbox");
ref.read(revisionProvider.notifier).bump();
Navigator.of(context).pop();
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.outgoing_mail,
title: "Sent",
onTap: () {
ref.read(folderProvider.notifier).select("sent");
ref.read(revisionProvider.notifier).bump();
Navigator.of(context).pop();
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.contacts,
title: "Contacts",
onTap: () {
Navigator.of(context).pop();
AutoRouter.of(context).push(ContactsRoute());
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.settings,
title: "Settings",
onTap: () {
Navigator.of(context).pop();
AutoRouter.of(context).push(SettingsRoute());
},
),
],
),
);
}
}

View file

@ -1,42 +0,0 @@
import "package:flutter/material.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
class DrawerListTile extends StatelessWidget {
final Color color;
final IconData icon;
final String title;
final void Function() onTap;
const DrawerListTile({
super.key,
required this.color,
required this.icon,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Theme(
data: Theme.of(context).copyWith(
splashColor: appColors.highlight,
highlightColor: appColors.cardFill,
),
child: ListTile(
onTap: onTap,
leading: Icon(icon, color: color, size: 18),
title: Text(
title,
style: TextStyle(
fontFamily: "Inter",
color: color,
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
);
}
}

View file

@ -1,64 +0,0 @@
import "package:flutter/material.dart";
import "package:smol_mail/shared/utils/format.dart";
/// Identity summary in the drawer: the address is what gets shared, the
/// fingerprint is what gets verified.
class IdentityHeader extends StatelessWidget {
final String address;
final String fingerprint;
const IdentityHeader({
super.key,
required this.address,
required this.fingerprint,
});
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
radius: 35,
child: Text(
address.isEmpty ? "?" : address[0].toUpperCase(),
style:
Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30),
),
),
const SizedBox(height: 20),
InkWell(
onTap: () => copyText(context, address),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
address.isEmpty ? "(not registered)" : address,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
),
const SizedBox(height: 5),
InkWell(
onTap: () => copyText(context, fingerprint),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
"fp: $fingerprint",
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
],
);
}
}

View file

@ -5,21 +5,23 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
class MessageList extends ConsumerWidget {
final String folder;
final Future<void> Function() onRefresh;
const MessageList({
super.key,
required this.folder,
required this.onRefresh,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider);
final messages = ref.watch(messagesForFolderProvider(folder));
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView.builder(
itemBuilder: (ctx, i) => MessageTile(record: messages[i]),
itemBuilder: (ctx, i) => MessageTile(folder: folder, record: messages[i]),
itemCount: messages.length,
),
);

View file

@ -11,13 +11,13 @@ import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class MessageTile extends ConsumerWidget {
final String folder;
final MailRecord record;
const MessageTile({super.key, required this.record});
const MessageTile({super.key, required this.folder, required this.record});
@override
Widget build(BuildContext context, WidgetRef ref) {
final folder = ref.watch(folderProvider);
final store = ref.watch(storeProvider);
final opened = ref.read(clientProvider).describe(record);
final appColors = Theme.of(context).extension<AppColors>()!;
@ -31,7 +31,7 @@ class MessageTile extends ConsumerWidget {
} else {
who = "?";
}
final unread = folder == "inbox" && !store.isRead(record.id);
final unread = folder != "sent" && !store.isRead(record.id);
final errorText = opened.error != null ? "<unreadable>" : null;
final subject =
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
@ -44,7 +44,7 @@ class MessageTile extends ConsumerWidget {
splashColor: appColors.highlight,
focusColor: appColors.cardFill,
onTap: () {
if (folder == "inbox") {
if (folder != "sent") {
store.markRead(record.id);
ref.read(revisionProvider.notifier).bump();
}

View file

@ -15,12 +15,14 @@ import "package:smol_mail/smol/store.dart";
/// The opened message: trust row (fingerprint and how the sender's key is
/// known), frontmatter fields, and the plain body — smol mail has no HTML.
class MessageView extends ConsumerWidget {
final String folder;
final MailRecord record;
final OpenedRecord opened;
final void Function(Uint8List senderKey)? onNameSender;
const MessageView({
super.key,
required this.folder,
required this.record,
required this.opened,
this.onNameSender,
@ -38,18 +40,19 @@ class MessageView extends ConsumerWidget {
);
}
final folder = ref.watch(folderProvider);
final store = ref.watch(storeProvider);
final client = ref.read(clientProvider);
// Sent copies carry no signature from a third party; the trust row then
// just says what they are.
final isSent = folder == "sent";
final isRequest = folder == "requests";
final senderKey = opened.sender;
final known = senderKey == null ? null : store.addressForKey(senderKey);
final contact = known == null ? null : store.contact(known);
final verifiedSender = contact?.verified ?? false;
final replyTo = isSent ? null : client.replyAddress(opened);
final accepted = known == null ? null : store.accepted(known);
final subject =
opened.subject.isEmpty ? "(no subject)" : opened.subject;
@ -69,6 +72,10 @@ class MessageView extends ConsumerWidget {
: "key bound to no address",
alert: known == null,
),
if (isRequest) ...[
const SizedBox(height: 10),
_badge(context, "arrived without an accept token (SPEC.md §5.8)", alert: true),
],
if (!isSent && senderKey != null) ...[
const SizedBox(height: 10),
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
@ -78,7 +85,7 @@ class MessageView extends ConsumerWidget {
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
_field(context, "Date", formatTime(opened.time)),
for (final entry in opened.fields.entries)
if (entry.key != "Subject" && entry.key != "Reply-To")
if (entry.key != "subject" && entry.key != "reply-to" && entry.key != "accept")
_field(context, entry.key, entry.value),
if (!isSent && replyTo != null)
Row(
@ -114,6 +121,27 @@ class MessageView extends ConsumerWidget {
child: const Text("Name this sender"),
),
),
if (isRequest && known != null && !(accepted?.active ?? false))
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
icon: const Icon(Icons.check_circle_outline, size: 18),
label: const Text("Accept sender"),
onPressed: () async {
try {
await client.acceptContact(known);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("$known accepted")),
);
}
} catch (err) {
if (context.mounted) showErrorSnackBar(context, err.toString());
}
},
),
),
const SizedBox(height: 10),
const Divider(height: 1),
const SizedBox(height: 20),