feat: thread view with reconcile guard against empty IMAP server response
- Add EmailThread model and observeThreads() grouping emails by RFC 2822 References/In-Reply-To headers (IMAP) or native threadId (JMAP) - Store threadId/messageId/inReplyTo/references in DB (schema v14) - Switch EmailListScreen to thread-grouped view; flag icon preserved - Guard _reconcileDeletedImap against wiping local cache when server returns 0 UIDs (network glitch / buggy IMAP server) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
acf9d14043
commit
0980ef2d08
@@ -31,12 +31,6 @@ Try Qwen, vscode plugin
|
||||
|
||||
---
|
||||
|
||||
test/unit/fake_imap.dart
|
||||
|
||||
Why is that still needed? We have Stalwart.
|
||||
|
||||
---
|
||||
|
||||
After Try Connection, show some matching icon next to the text.
|
||||
|
||||
---
|
||||
@@ -47,12 +41,6 @@ Test with a Fastmail account
|
||||
|
||||
---
|
||||
|
||||
LINTING.md
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
scripts/check_coverage.dart
|
||||
reduce files in _excluded.
|
||||
|
||||
|
||||
@@ -4,12 +4,23 @@
|
||||
|
||||
Do one thing, ask if unsure first!
|
||||
|
||||
The implement.
|
||||
Then implement.
|
||||
|
||||
Then run `task check`.
|
||||
|
||||
Then move task to DONE.md
|
||||
|
||||
Then commit.
|
||||
|
||||
After commit, remove the item from this document.
|
||||
|
||||
## Tasks
|
||||
|
||||
Implement thread-view.
|
||||
|
||||
First create a plan.
|
||||
|
||||
For JMAP this is easy.
|
||||
|
||||
But for IMAP?
|
||||
|
||||
Threads should be synced to the DB, too. Use JMAP as an example. Then think about getting this data
|
||||
structure from imap.
|
||||
|
||||
@@ -14,6 +14,11 @@ class Email {
|
||||
final bool isSeen;
|
||||
final bool isFlagged;
|
||||
final bool hasAttachment;
|
||||
final String? threadId;
|
||||
final String? messageId;
|
||||
final String? inReplyTo;
|
||||
// Space-separated RFC 2822 References header value.
|
||||
final String? references;
|
||||
|
||||
const Email({
|
||||
required this.id,
|
||||
@@ -30,6 +35,41 @@ class Email {
|
||||
required this.isSeen,
|
||||
required this.isFlagged,
|
||||
required this.hasAttachment,
|
||||
this.threadId,
|
||||
this.messageId,
|
||||
this.inReplyTo,
|
||||
this.references,
|
||||
});
|
||||
}
|
||||
|
||||
/// A group of related emails sharing the same thread.
|
||||
class EmailThread {
|
||||
final String threadId;
|
||||
final String? subject;
|
||||
final List<EmailAddress> participants;
|
||||
final DateTime latestDate;
|
||||
final int messageCount;
|
||||
final bool hasUnread;
|
||||
final bool isFlagged;
|
||||
final String latestEmailId;
|
||||
final String accountId;
|
||||
final String mailboxPath;
|
||||
|
||||
// All email IDs in this thread (oldest-first). Needed for batch operations.
|
||||
final List<String> emailIds;
|
||||
|
||||
const EmailThread({
|
||||
required this.threadId,
|
||||
required this.subject,
|
||||
required this.participants,
|
||||
required this.latestDate,
|
||||
required this.messageCount,
|
||||
required this.hasUnread,
|
||||
required this.isFlagged,
|
||||
required this.latestEmailId,
|
||||
required this.emailIds,
|
||||
required this.accountId,
|
||||
required this.mailboxPath,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@ import '../models/email.dart';
|
||||
|
||||
abstract class EmailRepository {
|
||||
Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
|
||||
|
||||
/// Groups emails by threadId and returns one [EmailThread] per thread,
|
||||
/// sorted by the latest message date descending.
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
);
|
||||
Future<Email?> getEmail(String emailId);
|
||||
Future<EmailBody> getEmailBody(String emailId);
|
||||
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
||||
|
||||
@@ -65,6 +65,12 @@ class Emails extends Table {
|
||||
BoolColumn get isFlagged => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get hasAttachment =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
// Added in schema v14: email threading.
|
||||
TextColumn get threadId => text().nullable()();
|
||||
TextColumn get messageId => text().nullable()();
|
||||
TextColumn get inReplyTo => text().nullable()();
|
||||
// Space-separated list of Message-IDs (RFC 2822 References header).
|
||||
TextColumn get references => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
@@ -189,7 +195,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 13;
|
||||
int get schemaVersion => 14;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -235,6 +241,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.addColumn(accounts, accounts.verbose);
|
||||
await m.addColumn(syncLogs, syncLogs.protocolLog);
|
||||
}
|
||||
if (from < 14) {
|
||||
await m.addColumn(emails, emails.threadId);
|
||||
await m.addColumn(emails, emails.messageId);
|
||||
await m.addColumn(emails, emails.inReplyTo);
|
||||
await m.addColumn(emails, emails.references);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,61 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.map((rows) => rows.map(_toModel).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<model.EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) {
|
||||
return observeEmails(accountId, mailboxPath).map(_groupIntoThreads);
|
||||
}
|
||||
|
||||
static List<model.EmailThread> _groupIntoThreads(List<model.Email> emails) {
|
||||
// Group emails by threadId, falling back to email id for unthreaded mail.
|
||||
final groups = <String, List<model.Email>>{};
|
||||
for (final email in emails) {
|
||||
final key = email.threadId ?? email.id;
|
||||
groups.putIfAbsent(key, () => []).add(email);
|
||||
}
|
||||
|
||||
final threads = groups.values.map((threadEmails) {
|
||||
// Sort within thread oldest-first so latest is last.
|
||||
threadEmails.sort((a, b) {
|
||||
final da = a.sentAt ?? a.receivedAt;
|
||||
final db = b.sentAt ?? b.receivedAt;
|
||||
return da.compareTo(db);
|
||||
});
|
||||
|
||||
final latest = threadEmails.last;
|
||||
|
||||
// Collect unique participants across the whole thread.
|
||||
final seen = <String>{};
|
||||
final participants = <model.EmailAddress>[];
|
||||
for (final e in threadEmails) {
|
||||
for (final a in e.from) {
|
||||
if (seen.add(a.email)) participants.add(a);
|
||||
}
|
||||
}
|
||||
|
||||
return model.EmailThread(
|
||||
threadId: latest.threadId ?? latest.id,
|
||||
subject: latest.subject,
|
||||
participants: participants,
|
||||
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||
messageCount: threadEmails.length,
|
||||
hasUnread: threadEmails.any((e) => !e.isSeen),
|
||||
isFlagged: threadEmails.any((e) => e.isFlagged),
|
||||
latestEmailId: latest.id,
|
||||
emailIds: threadEmails.map((e) => e.id).toList(),
|
||||
accountId: latest.accountId,
|
||||
mailboxPath: latest.mailboxPath,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Sort threads by latest message descending.
|
||||
threads.sort((a, b) => b.latestDate.compareTo(a.latestDate));
|
||||
return threads;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<model.Email?> getEmail(String emailId) async {
|
||||
final row = await (_db.select(_db.emails)
|
||||
@@ -383,15 +438,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
String mailboxPath,
|
||||
imap.MessageSequence sequence,
|
||||
) async {
|
||||
const fetchItems =
|
||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (REFERENCES)])';
|
||||
final fetch = sequence.isUidSequence
|
||||
? await client.uidFetchMessages(
|
||||
sequence,
|
||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE)',
|
||||
)
|
||||
: await client.fetchMessages(
|
||||
sequence,
|
||||
'(UID FLAGS ENVELOPE BODYSTRUCTURE RFC822.SIZE)',
|
||||
);
|
||||
? await client.uidFetchMessages(sequence, fetchItems)
|
||||
: await client.fetchMessages(sequence, fetchItems);
|
||||
var bytes = 0;
|
||||
await _db.transaction(() async {
|
||||
for (final msg in fetch.messages) {
|
||||
@@ -407,6 +458,15 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
bytes += msg.size ?? 0;
|
||||
final emailId = '${account.id}:$uid';
|
||||
final msgId = envelope.messageId?.trim();
|
||||
final inReplyTo = envelope.inReplyTo?.trim();
|
||||
final refs = msg.getHeaderValue('References')?.trim();
|
||||
final threadId = _computeThreadId(
|
||||
emailId: emailId,
|
||||
messageId: msgId,
|
||||
inReplyTo: inReplyTo,
|
||||
references: refs,
|
||||
);
|
||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: emailId,
|
||||
@@ -422,6 +482,10 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
||||
isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false),
|
||||
hasAttachment: Value(msg.hasAttachments()),
|
||||
threadId: Value(threadId),
|
||||
messageId: Value(msgId),
|
||||
inReplyTo: Value(inReplyTo),
|
||||
references: Value(refs),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -495,6 +559,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
static const _emailProperties = [
|
||||
'id',
|
||||
'threadId',
|
||||
'mailboxIds',
|
||||
'subject',
|
||||
'sentAt',
|
||||
@@ -505,6 +570,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
'keywords',
|
||||
'hasAttachment',
|
||||
'preview',
|
||||
'messageId',
|
||||
'inReplyTo',
|
||||
'references',
|
||||
'textBody',
|
||||
'htmlBody',
|
||||
'bodyValues',
|
||||
@@ -678,6 +746,15 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final receivedAt =
|
||||
_parseDate(m['receivedAt'] as String?) ?? DateTime.now();
|
||||
|
||||
final jmapThreadId = m['threadId'] as String?;
|
||||
// JMAP messageId/inReplyTo/references are arrays; join to space-separated.
|
||||
final jmapMessageId =
|
||||
_joinJmapStringList(m['messageId'] as List<dynamic>?);
|
||||
final jmapInReplyTo =
|
||||
_joinJmapStringList(m['inReplyTo'] as List<dynamic>?);
|
||||
final jmapReferences =
|
||||
_joinJmapStringList(m['references'] as List<dynamic>?);
|
||||
|
||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: dbId,
|
||||
@@ -694,6 +771,10 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
isSeen: Value(keywords.containsKey(r'$seen')),
|
||||
isFlagged: Value(keywords.containsKey(r'$flagged')),
|
||||
hasAttachment: Value((m['hasAttachment'] as bool?) ?? false),
|
||||
threadId: Value(jmapThreadId),
|
||||
messageId: Value(jmapMessageId),
|
||||
inReplyTo: Value(jmapInReplyTo),
|
||||
references: Value(jmapReferences),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1728,6 +1809,32 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Computes a stable threadId from RFC 2822 headers.
|
||||
/// Uses the first entry in References (= oldest ancestor) so all messages
|
||||
/// in a thread share the same root Message-ID as their threadId.
|
||||
/// Falls back to In-Reply-To, then own Message-ID, then internal emailId.
|
||||
/// JMAP header fields like messageId/inReplyTo/references come as arrays.
|
||||
/// We join them space-separated to match the IMAP convention.
|
||||
static String? _joinJmapStringList(List<dynamic>? list) {
|
||||
if (list == null || list.isEmpty) return null;
|
||||
final joined = list.cast<String>().join(' ');
|
||||
return joined.isEmpty ? null : joined;
|
||||
}
|
||||
|
||||
static String? _computeThreadId({
|
||||
required String emailId,
|
||||
required String? messageId,
|
||||
required String? inReplyTo,
|
||||
required String? references,
|
||||
}) {
|
||||
if (references != null && references.isNotEmpty) {
|
||||
final first = references.trim().split(RegExp(r'\s+')).firstOrNull;
|
||||
if (first != null && first.isNotEmpty) return first;
|
||||
}
|
||||
if (inReplyTo != null && inReplyTo.isNotEmpty) return inReplyTo;
|
||||
return messageId; // null for messages with no Message-ID (rare)
|
||||
}
|
||||
|
||||
String _encodeAddresses(List<imap.MailAddress>? addresses) => jsonEncode(
|
||||
(addresses ?? const [])
|
||||
.map((a) => {'name': a.personalName, 'email': a.email})
|
||||
@@ -1762,6 +1869,10 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
isSeen: row.isSeen,
|
||||
isFlagged: row.isFlagged,
|
||||
hasAttachment: row.hasAttachment,
|
||||
threadId: row.threadId,
|
||||
messageId: row.messageId,
|
||||
inReplyTo: row.inReplyTo,
|
||||
references: row.references,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'screens/search_screen.dart';
|
||||
import 'screens/sieve_script_edit_screen.dart';
|
||||
import 'screens/sieve_scripts_screen.dart';
|
||||
import 'screens/sync_log_screen.dart';
|
||||
import 'screens/thread_detail_screen.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/accounts',
|
||||
@@ -84,6 +85,18 @@ final router = GoRouter(
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: ':mailboxPath/threads/:threadId',
|
||||
builder: (ctx, state) => ThreadDetailScreen(
|
||||
accountId: state.pathParameters['accountId']!,
|
||||
mailboxPath: Uri.decodeComponent(
|
||||
state.pathParameters['mailboxPath']!,
|
||||
),
|
||||
threadId: Uri.decodeComponent(
|
||||
state.pathParameters['threadId']!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -31,8 +31,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
List<Email>? _searchResults;
|
||||
bool _searchLoading = false;
|
||||
|
||||
final Set<String> _selectedIds = {};
|
||||
bool get _selecting => _selectedIds.isNotEmpty;
|
||||
// Thread-level selection (key = threadId).
|
||||
final Set<String> _selectedThreadIds = {};
|
||||
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
||||
List<EmailThread> _currentThreads = [];
|
||||
bool get _selecting => _selectedThreadIds.isNotEmpty;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -40,17 +43,23 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleSelection(String id) {
|
||||
void _toggleThreadSelection(EmailThread thread) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(id)) {
|
||||
_selectedIds.remove(id);
|
||||
if (_selectedThreadIds.contains(thread.threadId)) {
|
||||
_selectedThreadIds.remove(thread.threadId);
|
||||
} else {
|
||||
_selectedIds.add(id);
|
||||
_selectedThreadIds.add(thread.threadId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() => setState(() => _selectedIds.clear());
|
||||
void _clearSelection() => setState(() => _selectedThreadIds.clear());
|
||||
|
||||
// All email IDs belonging to currently selected threads.
|
||||
List<String> get _selectedEmailIds => _currentThreads
|
||||
.where((t) => _selectedThreadIds.contains(t.threadId))
|
||||
.expand((t) => t.emailIds)
|
||||
.toList();
|
||||
|
||||
Future<void> _runSearch(String query) async {
|
||||
if (query.trim().isEmpty) {
|
||||
@@ -144,7 +153,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
title: Text('${_selectedIds.length} selected'),
|
||||
title: Text('${_selectedThreadIds.length} selected'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,45 +226,28 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
if (_searchResults!.isEmpty) {
|
||||
return const Center(child: Text('No results'));
|
||||
}
|
||||
return _buildList(_searchResults!);
|
||||
return _buildEmailList(_searchResults!);
|
||||
}
|
||||
|
||||
Widget _buildStreamBody(EmailRepository emailRepo) {
|
||||
return StreamBuilder<List<Email>>(
|
||||
stream: emailRepo.observeEmails(widget.accountId, widget.mailboxPath),
|
||||
return StreamBuilder<List<EmailThread>>(
|
||||
stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final emails = snap.data!;
|
||||
if (emails.isEmpty) {
|
||||
final threads = snap.data!;
|
||||
_currentThreads = threads;
|
||||
if (threads.isEmpty) {
|
||||
return const Center(child: Text('No emails'));
|
||||
}
|
||||
return _buildList(emails);
|
||||
return _buildThreadList(threads);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _archiveEmail(Email email) async {
|
||||
final archive = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
.findMailboxByRole(widget.accountId, 'archive');
|
||||
if (!mounted) return;
|
||||
if (archive == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No archive folder found')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ref.read(emailRepositoryProvider).moveEmail(email.id, archive.path);
|
||||
}
|
||||
|
||||
Future<void> _deleteEmail(Email email) async {
|
||||
await ref.read(emailRepositoryProvider).deleteEmail(email.id);
|
||||
}
|
||||
|
||||
Future<void> _batchArchive() async {
|
||||
final ids = Set<String>.from(_selectedIds);
|
||||
final ids = _selectedEmailIds;
|
||||
_clearSelection();
|
||||
final archive = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
@@ -274,7 +266,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _batchDelete() async {
|
||||
final ids = Set<String>.from(_selectedIds);
|
||||
final ids = _selectedEmailIds;
|
||||
_clearSelection();
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
for (final id in ids) {
|
||||
@@ -283,7 +275,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _batchMarkSpam() async {
|
||||
final ids = Set<String>.from(_selectedIds);
|
||||
final ids = _selectedEmailIds;
|
||||
_clearSelection();
|
||||
final junk = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
@@ -302,7 +294,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _batchMove() async {
|
||||
final ids = Set<String>.from(_selectedIds);
|
||||
final ids = _selectedEmailIds;
|
||||
final mailboxes = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
.observeMailboxes(widget.accountId)
|
||||
@@ -341,33 +333,48 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildList(List<Email> emails) {
|
||||
Widget _buildThreadList(List<EmailThread> threads) {
|
||||
return ListView.builder(
|
||||
itemCount: emails.length,
|
||||
itemCount: threads.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = emails[i];
|
||||
final isSelected = _selectedIds.contains(e.id);
|
||||
final sender = e.from.isNotEmpty
|
||||
? (e.from.first.name ?? e.from.first.email)
|
||||
: '(unknown)';
|
||||
final t = threads[i];
|
||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
||||
final senderNames =
|
||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||
|
||||
final tile = ListTile(
|
||||
leading: _selecting
|
||||
? Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) => _toggleSelection(e.id),
|
||||
onChanged: (_) => _toggleThreadSelection(t),
|
||||
)
|
||||
: Icon(
|
||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style:
|
||||
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
senderNames.isEmpty ? '(unknown)' : senderNames,
|
||||
style: t.hasUnread
|
||||
? const TextStyle(fontWeight: FontWeight.bold)
|
||||
: null,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (t.messageCount > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Text(
|
||||
'[${t.messageCount}]',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
e.subject ?? '(no subject)',
|
||||
t.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -377,29 +384,33 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (e.isFlagged)
|
||||
if (t.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!) : '',
|
||||
_dateFmt.format(t.latestDate),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: _selecting
|
||||
? () => _toggleSelection(e.id)
|
||||
: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
||||
),
|
||||
onLongPress: () => _toggleSelection(e.id),
|
||||
? () => _toggleThreadSelection(t)
|
||||
: t.messageCount > 1
|
||||
? () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
)
|
||||
: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
),
|
||||
onLongPress: () => _toggleThreadSelection(t),
|
||||
);
|
||||
|
||||
if (_selecting) return tile;
|
||||
|
||||
// For swipe actions on threads, operate on the latest email only
|
||||
// (single-email threads) or the whole thread.
|
||||
return Dismissible(
|
||||
key: ValueKey(e.id),
|
||||
key: ValueKey(t.threadId),
|
||||
background: _swipeBackground(
|
||||
alignment: Alignment.centerLeft,
|
||||
color: Colors.green,
|
||||
@@ -413,10 +424,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
label: 'Delete',
|
||||
),
|
||||
onDismissed: (direction) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
await _archiveEmail(e);
|
||||
final archive = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
.findMailboxByRole(widget.accountId, 'archive');
|
||||
if (!mounted || archive == null) return;
|
||||
for (final id in t.emailIds) {
|
||||
await repo.moveEmail(id, archive.path);
|
||||
}
|
||||
} else {
|
||||
await _deleteEmail(e);
|
||||
for (final id in t.emailIds) {
|
||||
await repo.deleteEmail(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: tile,
|
||||
@@ -425,6 +445,42 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Used for search results, which are individual emails.
|
||||
Widget _buildEmailList(List<Email> emails) {
|
||||
return ListView.builder(
|
||||
itemCount: emails.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = emails[i];
|
||||
final sender = e.from.isNotEmpty
|
||||
? (e.from.first.name ?? e.from.first.email)
|
||||
: '(unknown)';
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style:
|
||||
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
e.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
onTap: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swipeBackground({
|
||||
required AlignmentGeometry alignment,
|
||||
required Color color,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/models/email.dart';
|
||||
import '../../di.dart';
|
||||
|
||||
final _dateFmt = DateFormat('MMM d');
|
||||
|
||||
class ThreadDetailScreen extends ConsumerWidget {
|
||||
const ThreadDetailScreen({
|
||||
super.key,
|
||||
required this.accountId,
|
||||
required this.mailboxPath,
|
||||
required this.threadId,
|
||||
});
|
||||
|
||||
final String accountId;
|
||||
final String mailboxPath;
|
||||
final String threadId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Thread')),
|
||||
body: StreamBuilder<List<EmailThread>>(
|
||||
stream: repo.observeThreads(accountId, mailboxPath),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final thread =
|
||||
snap.data!.where((t) => t.threadId == threadId).firstOrNull;
|
||||
if (thread == null) {
|
||||
return const Center(child: Text('Thread not found'));
|
||||
}
|
||||
// Re-fetch the individual emails from observeEmails to show them.
|
||||
return StreamBuilder<List<Email>>(
|
||||
stream: repo.observeEmails(accountId, mailboxPath),
|
||||
builder: (ctx, emailSnap) {
|
||||
if (!emailSnap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final emails = emailSnap.data!
|
||||
.where(
|
||||
(e) => (e.threadId ?? e.id) == threadId,
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final da = a.sentAt ?? a.receivedAt;
|
||||
final db = b.sentAt ?? b.receivedAt;
|
||||
return da.compareTo(db);
|
||||
});
|
||||
|
||||
if (emails.isEmpty) {
|
||||
return const Center(child: Text('No messages'));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: emails.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = emails[i];
|
||||
final sender = e.from.isNotEmpty
|
||||
? (e.from.first.name ?? e.from.first.email)
|
||||
: '(unknown)';
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color:
|
||||
e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style: e.isSeen
|
||||
? null
|
||||
: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
e.preview ?? e.subject ?? '(no subject)',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: 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)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ class _FakeEmails implements EmailRepository {
|
||||
@override
|
||||
Stream<List<Email>> observeEmails(String a, String m) => Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(String a, String m) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String id) async => null;
|
||||
|
||||
|
||||
@@ -72,6 +72,13 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => null;
|
||||
|
||||
|
||||
@@ -154,6 +154,29 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) =>
|
||||
Stream.value(List.of(_emails));
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) =>
|
||||
observeEmails(accountId, mailboxPath).map((emails) {
|
||||
return emails.map((e) {
|
||||
return EmailThread(
|
||||
threadId: e.threadId ?? e.id,
|
||||
subject: e.subject,
|
||||
participants: e.from,
|
||||
latestDate: e.sentAt ?? e.receivedAt,
|
||||
messageCount: 1,
|
||||
hasUnread: !e.isSeen,
|
||||
isFlagged: e.isFlagged,
|
||||
latestEmailId: e.id,
|
||||
emailIds: [e.id],
|
||||
accountId: e.accountId,
|
||||
mailboxPath: e.mailboxPath,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user