UI gaps: account picker in compose, flag button in detail; update docs

- compose_screen: show From dropdown when >1 account, auto-select first
  account when none pre-selected (fixes silent failure on new mail)
- email_detail_screen: add flag/unflag star button with amber highlight
- PLAN.md: collapse completed phases, list remaining UI gaps
- README: fix stale "vendored" package reference, update platform table,
  add Working Features section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 10:17:14 +02:00
co-authored by Claude Sonnet 4.6
parent 0e2021f343
commit 4c6c741e00
4 changed files with 105 additions and 22 deletions
+7 -7
View File
@@ -24,16 +24,16 @@ UI never touches the network. The sync layer runs independently.
| 3 — IMAP sync | `connectImap`, `MailboxRepositoryImpl.syncMailboxes`, `EmailRepositoryImpl.syncEmails` | Done |
| 4 — IMAP IDLE | `AccountSyncManager` with exponential-backoff reconnect | Done |
| 5 — SMTP send | `connectSmtp`, `EmailRepositoryImpl.sendEmail` | Done |
| 6 — UI | All screens: AccountList, AddAccount, MailboxList, EmailList, EmailDetail, Compose, Settings | Done |
| 7 — Dev tooling | Nix flake, `.envrc`, Taskfile, Stalwart dev server (IMAP+SMTP), integration tests | Done |
| 8 — Code-gen | Run `task codegen` to generate `database.g.dart` and Riverpod providers | Pending |
| 9 — Platform targets | Android, iOS, Linux, macOS, Windows entry points | Pending |
| 10 — Polish | Reply prefill, attachment open, thread view, search | Next |
| 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** |
## Next candidates
- Reply-with-prefill (subject/body/from populated from original email)
- Thread view (group by `References` / `In-Reply-To`)
- 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
- Draft auto-save
+21 -8
View File
@@ -21,24 +21,22 @@ The UI never touches the network. The sync engine runs in the background and wri
| Platform | Status |
| --- | --- |
| Linux desktop | Working |
| macOS desktop | Entry point pending |
| Windows desktop | Entry point pending |
| Android | Entry point pending |
| iOS | Entry point pending |
| Linux desktop | Working (`task run`) |
| Android | APK builds (`task build-android`) |
| macOS desktop | Scaffolded |
| Windows desktop | Scaffolded |
| iOS | Scaffolded |
## Key packages
| Package | Role |
| --- | --- |
| `enough_mail` (vendored in `packages/`) | IMAP / SMTP / MIME |
| [`enough_mail`](https://pub.dev/packages/enough_mail) | IMAP / SMTP / MIME |
| `drift` | Local SQLite ORM (offline-first store) |
| `flutter_riverpod` | State management / DI |
| `go_router` | Navigation |
| `flutter_secure_storage` | Password storage |
`enough_mail` is vendored under `packages/` so it can be patched without waiting for an upstream release.
---
## For users
@@ -136,3 +134,18 @@ test/
unit/ — pure-Dart unit tests (no device)
integration/ — IMAP/SMTP tests against local Stalwart
```
---
## Working features
- **Multiple accounts** — add any number of IMAP/SMTP accounts; each syncs independently
- **IMAP IDLE** — background sync with push-like latency; exponential backoff (5 s → 5 min) on error
- **Mailbox list** — shows all folders with unread / total counts
- **Email list** — sender, subject, date; bold for unread; manual sync button
- **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
- **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
+50 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/models/account.dart';
import '../../core/models/email.dart';
import '../../di.dart';
@@ -33,6 +34,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
final _subject = TextEditingController();
final _body = TextEditingController();
String? _accountId;
List<Account> _accounts = [];
bool _sending = false;
@override
@@ -43,6 +45,18 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
if (widget.prefillSubject != null) _subject.text = widget.prefillSubject!;
if (widget.prefillBody != null) _body.text = widget.prefillBody!;
_accountId = widget.accountId;
_loadAccounts();
}
Future<void> _loadAccounts() async {
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (!mounted) return;
setState(() {
_accounts = accounts;
// Auto-select the first account when none was pre-selected.
_accountId ??= accounts.isNotEmpty ? accounts.first.id : null;
});
}
@override
@@ -55,8 +69,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
Future<void> _send() async {
if (_accountId == null) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Select an account first')));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select an account first')),
);
return;
}
setState(() => _sending = true);
@@ -112,6 +127,39 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (_accounts.length > 1)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: DropdownButtonFormField<String>(
initialValue: _accountId,
decoration: const InputDecoration(
labelText: 'From',
border: OutlineInputBorder(),
),
items: _accounts
.map(
(a) => DropdownMenuItem(
value: a.id,
child: Text('${a.displayName} <${a.email}>'),
),
)
.toList(),
onChanged: (v) => setState(() => _accountId = v),
),
)
else if (_accounts.length == 1)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'From',
border: OutlineInputBorder(),
),
child: Text(
'${_accounts.first.displayName} <${_accounts.first.email}>',
),
),
),
_field(_to, 'To', keyboardType: TextInputType.emailAddress),
_field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
_field(_subject, 'Subject'),
+27 -5
View File
@@ -20,6 +20,7 @@ class EmailDetailScreen extends ConsumerStatefulWidget {
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
late final Future<(Email?, EmailBody)> _dataFuture;
bool _isFlagged = false;
@override
void initState() {
@@ -28,7 +29,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_dataFuture = Future.wait([
repo.getEmail(widget.emailId),
repo.getEmailBody(widget.emailId),
]).then((results) => (results[0] as Email?, results[1] as EmailBody));
]).then((results) {
final email = results[0] as Email?;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
return (email, results[1] as EmailBody);
});
repo.setFlag(widget.emailId, seen: true);
}
@@ -51,17 +58,32 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
IconButton(
icon: const Icon(Icons.reply),
tooltip: 'Reply',
onPressed:
header == null ? null : () => _reply(context, header, replyAll: false),
onPressed: header == null
? null
: () => _reply(context, header, replyAll: false),
),
IconButton(
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed:
header == null ? null : () => _reply(context, header, replyAll: true),
onPressed: header == null
? null
: () => _reply(context, header, replyAll: true),
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: () async {
await repo.deleteEmail(widget.emailId);
if (context.mounted) context.pop();