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

@ -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),