feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention
This commit is contained in:
parent
eaaa3f2ede
commit
c693e6fcb9
27 changed files with 1190 additions and 592 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
86
lib/presentation/screens/home_screen.dart
Normal file
86
lib/presentation/screens/home_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue