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:
Thomas Güttler
2026-04-24 15:12:04 +02:00
co-authored by Claude Sonnet 4.6
parent acf9d14043
commit 0980ef2d08
12 changed files with 460 additions and 87 deletions
-12
View File
@@ -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.
+14 -3
View File
@@ -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.
+40
View File
@@ -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);
+13 -1
View File
@@ -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,
);
}
+13
View File
@@ -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']!,
),
),
),
],
),
],
+119 -63
View File
@@ -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,
+101
View File
@@ -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;
+7
View File
@@ -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;
+23
View File
@@ -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;