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
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue