diff --git a/PLAN.md b/PLAN.md index 931dfe0..ee8b664 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/README.md b/README.md index 75805c9..4396699 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index 8caa381..87202af 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -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 { final _subject = TextEditingController(); final _body = TextEditingController(); String? _accountId; + List _accounts = []; bool _sending = false; @override @@ -43,6 +45,18 @@ class _ComposeScreenState extends ConsumerState { if (widget.prefillSubject != null) _subject.text = widget.prefillSubject!; if (widget.prefillBody != null) _body.text = widget.prefillBody!; _accountId = widget.accountId; + _loadAccounts(); + } + + Future _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 { Future _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 { body: ListView( padding: const EdgeInsets.all(16), children: [ + if (_accounts.length > 1) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: DropdownButtonFormField( + 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'), diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 2062c0f..2c14836 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -20,6 +20,7 @@ class EmailDetailScreen extends ConsumerStatefulWidget { class _EmailDetailScreenState extends ConsumerState { late final Future<(Email?, EmailBody)> _dataFuture; + bool _isFlagged = false; @override void initState() { @@ -28,7 +29,13 @@ class _EmailDetailScreenState extends ConsumerState { _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 { 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();