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:
co-authored by
Claude Sonnet 4.6
parent
0e2021f343
commit
4c6c741e00
@@ -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,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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user