feat: replace mail protocol with smolmail and rebuild the UI

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

View file

@ -1,53 +0,0 @@
import "package:flutter/widgets.dart";
import "package:flutter_inappwebview/flutter_inappwebview.dart";
import "package:url_launcher/url_launcher.dart";
class MessageBody extends StatefulWidget {
final String html;
const MessageBody({super.key, required this.html});
@override
State<MessageBody> createState() => _MessageBodyState();
}
class _MessageBodyState extends State<MessageBody> {
double webviewHeight = 1;
@override
Widget build(BuildContext context) {
return SizedBox(
height: webviewHeight,
child: InAppWebView(
initialData: InAppWebViewInitialData(data: widget.html),
initialSettings: InAppWebViewSettings(
transparentBackground: true,
forceDark: ForceDark.ON,
algorithmicDarkeningAllowed: true,
supportZoom: false,
javaScriptEnabled: true,
disableHorizontalScroll: false,
disableVerticalScroll: true,
useWideViewPort: false,
useHybridComposition: false,
preferredContentMode: UserPreferredContentMode.MOBILE,
),
onLoadStop: (controller, url) async {
final contentHeight = await controller.getContentHeight();
if (contentHeight != null) {
setState(() {
webviewHeight = contentHeight.toDouble();
});
}
},
shouldOverrideUrlLoading: (controller, navigationAction) async {
final url = navigationAction.request.url;
if (url != null && await canLaunchUrl(url)) {
await launchUrl(url);
}
return NavigationActionPolicy.CANCEL;
},
),
);
}
}

View file

@ -1,82 +0,0 @@
import "package:flutter/material.dart";
import "package:intl/intl.dart";
class MessageInfo extends StatelessWidget {
final String senderAddress;
final String receiverAddress;
final DateTime date;
const MessageInfo({
super.key,
required this.senderAddress,
required this.receiverAddress,
required this.date,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 15),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
border: Border.all(
color: const Color.fromRGBO(127, 133, 140, 1),
),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
"From",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(width: 10),
SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: Text(
senderAddress,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
)
],
),
Row(
children: [
Text(
"To",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(width: 10),
SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: Text(
receiverAddress,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
)
],
),
Row(
children: [
Text(
"Date",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(width: 10),
Text(
DateFormat.yMMMMd().add_jm().format(date.toLocal()),
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
)
],
),
],
),
);
}
}

View file

@ -1,32 +1,27 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/models/message/message.dart";
import "package:flash_mail/presentation/widgets/image_banner.dart";
import "package:flash_mail/presentation/widgets/message/message_tile.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
class MessageList extends StatelessWidget {
final List<TMMessage> messages;
class MessageList extends ConsumerWidget {
final Future<void> Function() onRefresh;
const MessageList({
super.key,
required this.messages,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider);
return RefreshIndicator(
onRefresh: onRefresh,
child: messages.isEmpty
? const ImageBanner(
imgSrc: "assets/images/empty.png",
text: "Your inbox is empty!",
)
: ListView.builder(
itemBuilder: (ctx, i) => MessageTile(message: messages[i]),
itemCount: messages.length,
),
child: ListView.builder(
itemBuilder: (ctx, i) => MessageTile(record: messages[i]),
itemCount: messages.length,
),
);
}
}

View file

@ -1,84 +0,0 @@
import "package:flutter/material.dart";
import "package:flash_mail/presentation/widgets/message/message_info.dart";
class MessageSender extends StatefulWidget {
final String senderName;
final String senderAddress;
final String receiverAddress;
final DateTime date;
const MessageSender({
super.key,
required this.senderName,
required this.senderAddress,
required this.receiverAddress,
required this.date,
});
@override
State<MessageSender> createState() => _MessageSenderState();
}
class _MessageSenderState extends State<MessageSender> {
bool showInfo = false;
void toggleInfo() {
setState(() {
showInfo = !showInfo;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Image.asset("assets/images/avatar.png", width: 50, height: 50),
const SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.senderName,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => toggleInfo(),
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.6,
child: Text(
widget.senderAddress,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
),
Icon(
showInfo
? Icons.keyboard_arrow_up_outlined
: Icons.keyboard_arrow_down_outlined,
color: const Color.fromRGBO(127, 133, 140, 1),
),
],
),
),
],
)
],
),
showInfo
? MessageInfo(
senderAddress: widget.senderAddress,
receiverAddress: widget.receiverAddress,
date: widget.date,
)
: const SizedBox.shrink(),
],
);
}
}

View file

@ -1,77 +1,101 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/models/message/message.dart";
import "package:flash_mail/presentation/routes/app_router.gr.dart";
import "package:flash_mail/shared/utils/random_color.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/avatar_color.dart";
import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class MessageTile extends StatefulWidget {
final TMMessage message;
class MessageTile extends ConsumerWidget {
final MailRecord record;
const MessageTile({super.key, required this.message});
const MessageTile({super.key, required this.record});
@override
State<MessageTile> createState() => _MessageTileState();
}
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>()!;
class _MessageTileState extends State<MessageTile> {
late Color randomColor;
String who;
if (folder == "sent") {
who = record.recipient ?? "";
} else if (opened.sender != null) {
final known = store.addressForKey(opened.sender!);
who = known ?? "unknown · ${b32encode(opened.sender!).substring(0, 4)}";
} else {
who = "?";
}
final unread = folder == "inbox" && !store.isRead(record.id);
final errorText = opened.error != null ? "<unreadable>" : null;
final subject =
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
final snippet = errorText == null
? opened.body.replaceAll(RegExp(r"\s+"), " ").trim()
: "";
final avatarColor = colorForKey(who, appColors.avatarPalette);
@override
void initState() {
super.initState();
randomColor = getRandomColor();
}
@override
Widget build(BuildContext context) {
return ListTile(
splashColor: const Color.fromRGBO(40, 70, 100, 1),
focusColor: const Color.fromRGBO(22, 39, 56, 1),
onTap: () => AutoRouter.of(context).push(
MessageDetailRoute(
id: widget.message.id,
subject: widget.message.subject,
),
),
splashColor: appColors.highlight,
focusColor: appColors.cardFill,
onTap: () {
if (folder == "inbox") {
store.markRead(record.id);
ref.read(revisionProvider.notifier).bump();
}
AutoRouter.of(context).push(
MessageDetailRoute(folder: folder, id: record.id),
);
},
leading: CircleAvatar(
radius: 25,
backgroundColor: randomColor,
backgroundColor: avatarColor,
child: Text(
widget.message.from["name"]!.toUpperCase()[0],
who.isEmpty ? "?" : who[0].toUpperCase(),
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: Colors.black,
color: contrastingTextColor(avatarColor),
fontWeight: FontWeight.w500,
),
),
),
title: Text(
widget.message.from["name"] as String,
who,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight:
widget.message.seen ? FontWeight.w500 : FontWeight.bold,
fontWeight: unread ? FontWeight.bold : FontWeight.w500,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.message.subject,
subject,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight:
widget.message.seen ? FontWeight.w300 : FontWeight.w500,
fontWeight: unread ? FontWeight.w500 : FontWeight.w300,
),
),
Text(
widget.message.intro ?? widget.message.subject,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color.fromRGBO(193, 203, 213, 1),
),
)
if (snippet.isNotEmpty)
Text(
snippet.length > 140 ? snippet.substring(0, 140) : snippet,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: appColors.snippetText,
),
)
],
),
trailing: Text(
formatShortTime(folder == "sent" ? record.sentAt : opened.time),
style: Theme.of(context).textTheme.labelSmall,
),
);
}
}

View file

@ -1,39 +1,190 @@
import "package:flutter/widgets.dart";
import "dart:typed_data";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flash_mail/data/models/message_detail/message_detail.dart";
import "package:flash_mail/data/providers/providers.dart";
import "package:flash_mail/presentation/widgets/attachment/attachment_list.dart";
import "package:flash_mail/presentation/widgets/message/message_body.dart";
import "package:flash_mail/presentation/widgets/message/message_sender.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class MessageView extends StatelessWidget {
final TMMessageDetail message;
/// 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 MailRecord record;
final OpenedRecord opened;
final void Function(Uint8List senderKey)? onNameSender;
const MessageView({super.key, required this.message});
const MessageView({
super.key,
required this.record,
required this.opened,
this.onNameSender,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
if (opened.error != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("<unreadable: ${opened.error}>",
style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
);
}
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 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 subject =
opened.subject.isEmpty ? "(no subject)" : opened.subject;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Consumer(
builder: (context, ref, child) => MessageSender(
senderName: message.from["name"] as String,
senderAddress: message.from["address"] as String,
receiverAddress: ref.read(emailProvider) as String,
date: message.createdAt,
Text(subject, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 14),
if (isSent) _badge(context, "your own sealed copy") else
_badge(
context,
verifiedSender
? "verified sender key"
: known != null
? "key pinned on first use"
: "key bound to no address",
alert: known == null,
),
if (!isSent && senderKey != null) ...[
const SizedBox(height: 10),
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
],
const SizedBox(height: 18),
_field(context, isSent ? "To" : "From",
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")
_field(context, entry.key, entry.value),
if (!isSent && replyTo != null)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _field(context, "Reply-To", replyTo.short)),
TextButton(
onPressed: () async {
try {
await client.saveReplyAddress(replyTo, senderKey!);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Address saved for this sender")),
);
}
} catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.toString());
}
}
},
child: const Text("Save"),
),
],
),
if (!isSent && senderKey != null && onNameSender != null)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => onNameSender!(senderKey),
child: const Text("Name this sender"),
),
),
const SizedBox(height: 10),
const Divider(height: 1),
const SizedBox(height: 20),
SelectableText(
opened.body,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 25),
MessageBody(
html: message.html[0],
),
message.hasAttachments
? AttachmentList(
attachments: message.attachments!,
)
: const SizedBox.shrink()
if (!isSent && senderKey != null)
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
icon: const Icon(Icons.reply, size: 18),
label: const Text("Reply"),
onPressed: () => AutoRouter.of(context).push(
ComposeRoute(
to: known ?? replyTo?.short ?? "",
subject: replySubject(opened.subject),
),
),
),
),
],
);
}
Widget _badge(BuildContext context, String text, {bool alert = false}) {
final scheme = Theme.of(context).colorScheme;
final background = alert ? scheme.errorContainer : scheme.primaryContainer;
final foreground = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: foreground, fontWeight: FontWeight.w500),
),
);
}
/// Technical detail (fingerprint, full key) — quiet by design, so it never
/// competes with the subject or body for attention.
Widget _keyBlock(BuildContext context, String text) => Text(
text,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12,
height: 1.5,
fontWeight: FontWeight.normal,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
Widget _field(BuildContext context, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(label, style: Theme.of(context).textTheme.labelSmall),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodySmall),
),
],
),
);
}