From 6c27ad655bd2c8f87a8043ae9d9b69f9d7a6990f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 16 Apr 2026 11:11:11 +0200 Subject: [PATCH] Complete UI gaps: move-to-folder, attachment indicators in list - email_detail: add Move-to-folder button (bottom-sheet mailbox picker, excludes current folder, calls moveEmail on server) - email_list: show amber star for flagged, paperclip for attachments in trailing area alongside date - PLAN.md: mark phase 8 done - README: add flag/move/attachment to working features Co-Authored-By: Claude Sonnet 4.6 --- PLAN.md | 5 +-- README.md | 3 ++ lib/ui/screens/email_detail_screen.dart | 47 +++++++++++++++++++++++-- lib/ui/screens/email_list_screen.dart | 16 +++++++-- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/PLAN.md b/PLAN.md index ee8b664..55a039a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -26,13 +26,10 @@ UI never touches the network. The sync layer runs independently. | 5 — SMTP send | `connectSmtp`, `EmailRepositoryImpl.sendEmail` | Done | | 6 — UI | AccountList, AddAccount, MailboxList, EmailList, EmailDetail, Compose, Settings | Done | | 7 — Dev tooling | Nix flake, Taskfile, Stalwart dev server, unit + integration tests, CI, pre-commit | Done | -| 8 — UI gaps | Account picker in compose, flag button, move-to-trash | **In progress** | +| 8 — UI gaps | Account picker in compose, flag/unflag, move-to-folder, attachment indicators | Done | ## Next candidates -- Account picker in compose screen (currently broken for new mail with >1 account) -- Flag / star button in email detail -- Move to trash from email detail - Search (IMAP `SEARCH` command) - Thread view (group by `References` / `In-Reply-To`) - Attachment download + open diff --git a/README.md b/README.md index 4396699..d52942a 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,9 @@ test/ - **Email detail** — renders plain text; falls back to HTML→plain conversion; marks as read on open; shows attachment names and sizes - **Reply / Reply all** — pre-fills To, Subject (`Re:`), Cc from original - **Compose** — To, Cc, Subject, Body fields; sends via SMTP +- **Flag / unflag** — star button in detail view; amber star indicator in list; synced to server +- **Move to folder** — bottom-sheet folder picker; moves on server via IMAP MOVE +- **Attachment indicators** — paperclip icon in email list; filename + size in detail - **Delete email** — removes from server (IMAP expunge) and local DB - **Settings** — list and remove accounts - **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 2c14836..6be7680 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -81,6 +81,11 @@ class _EmailDetailScreenState extends ConsumerState { if (mounted) setState(() => _isFlagged = next); }, ), + IconButton( + icon: const Icon(Icons.drive_file_move_outline), + tooltip: 'Move to folder', + onPressed: header == null ? null : () => _moveTo(context, header), + ), IconButton( icon: const Icon(Icons.delete), tooltip: 'Delete', @@ -167,9 +172,7 @@ class _EmailDetailScreenState extends ConsumerState { final subject = (header.subject?.startsWith('Re:') ?? false) ? header.subject! : 'Re: ${header.subject ?? ''}'; - final cc = replyAll - ? header.to.map((a) => a.email).join(', ') - : ''; + final cc = replyAll ? header.to.map((a) => a.email).join(', ') : ''; context.push('/compose', extra: { 'replyToEmailId': widget.emailId, 'prefillTo': to, @@ -178,4 +181,42 @@ class _EmailDetailScreenState extends ConsumerState { }); } + Future _moveTo(BuildContext context, Email header) async { + final mailboxRepo = ref.read(mailboxRepositoryProvider); + final mailboxes = + await mailboxRepo.observeMailboxes(header.accountId).first; + + // Remove the current mailbox from the list. + final destinations = mailboxes + .where((m) => m.path != header.mailboxPath) + .toList(); + + if (!context.mounted) return; + + final chosen = await showModalBottomSheet( + context: context, + builder: (ctx) => ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text( + 'Move to…', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + for (final m in destinations) + ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(m.name), + onTap: () => Navigator.pop(ctx, m.path), + ), + ], + ), + ); + + if (chosen == null || !context.mounted) return; + + await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen); + if (context.mounted) context.pop(); + } } diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 8aaf4d0..3e06889 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -71,9 +71,19 @@ class EmailListScreen extends ConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - trailing: Text( - e.sentAt != null ? _dateFmt.format(e.sentAt!) : '', - style: Theme.of(ctx).textTheme.bodySmall, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (e.isFlagged) + const Icon(Icons.star, color: Colors.amber, size: 16), + if (e.hasAttachment) + const Icon(Icons.attach_file, size: 16), + const SizedBox(width: 4), + Text( + e.sentAt != null ? _dateFmt.format(e.sentAt!) : '', + style: Theme.of(ctx).textTheme.bodySmall, + ), + ], ), onTap: () => context.push( '/accounts/$accountId/mailboxes/${Uri.encodeComponent(mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',