Implement Thread View UI and repository support
- Created ThreadDetailScreen with expandable email cards and HTML support. - Added observeEmailsInThread to EmailRepository and implementation. - Updated navigation in EmailListScreen to route multi-message threads to the new view. - Updated test mocks (FakeEmailRepository) across unit, widget, and integration tests. - Documented progress in done.md and updated next.md.
This commit is contained in:
@@ -6,6 +6,81 @@ Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks
|
||||
|
||||
## Thread View UI and Repository Support
|
||||
|
||||
Implemented a dedicated screen to view all emails within a thread, providing
|
||||
a cohesive conversation view.
|
||||
|
||||
- **ThreadDetailScreen**: A new screen (`lib/ui/screens/thread_detail_screen.dart`)
|
||||
that displays a list of emails in a thread. Each email is rendered as an
|
||||
expandable card, with the latest message expanded by default.
|
||||
- **HTML Support**: Integrated HTML rendering with remote image blocking
|
||||
(reusing logic from `EmailDetailScreen`) into the thread view.
|
||||
- **Message Actions**: Added reply and delete actions for individual messages
|
||||
within the thread.
|
||||
- **Repository Support**: Added `observeEmailsInThread` to `EmailRepository`
|
||||
to fetch and watch all messages belonging to a specific thread ID.
|
||||
- **Navigation**: Updated `EmailListScreen` to navigate to the new thread view
|
||||
when a thread with multiple messages is tapped.
|
||||
- **Mock Support**: Updated `FakeEmailRepository` in unit, widget, and
|
||||
integration tests to support the new `observeEmailsInThread` method.
|
||||
|
||||
## Database-Backed Threading and Performance Optimizations
|
||||
|
||||
Refactored the threading logic from in-memory grouping to a persistent
|
||||
database-backed approach for improved performance and scalability.
|
||||
|
||||
- **Threads Table**: Added a new `Threads` table to the SQLite database
|
||||
(Schema v17/v18) to store aggregated thread metadata (subject, unread
|
||||
status, participants, etc.).
|
||||
- **Automatic Sync**: Implemented `_updateThread` logic in `EmailRepositoryImpl`
|
||||
to keep the `Threads` table synchronized during IMAP/JMAP syncs and
|
||||
user actions (flag changes, moves, deletions).
|
||||
- **Migration**: Added migration logic to automatically populate the `Threads`
|
||||
table from existing email data upon schema upgrade.
|
||||
- **Indexes**: Added performance indexes on `emails.receivedAt`,
|
||||
`emails.threadId`, and `pending_changes.accountId` to speed up common
|
||||
query patterns for large mailboxes.
|
||||
- **Repository Refactor**: Updated `observeThreads` to query the `Threads`
|
||||
table directly, significantly reducing CPU and memory usage when
|
||||
displaying the inbox.
|
||||
|
||||
## Global Crash Screen and Error Handling
|
||||
|
||||
Implemented a robust error handling system to capture and display unhandled
|
||||
exceptions to users, facilitating easier bug reporting.
|
||||
|
||||
- **CrashScreen**: A new full-screen widget (`lib/ui/screens/crash_screen.dart`)
|
||||
that displays the exception message, stack trace, and a "Copy to Clipboard"
|
||||
button for easy sharing of error details.
|
||||
- **Global Handlers**: Wrapped `main()` in `runZonedGuarded` to catch unhandled
|
||||
async errors.
|
||||
- **Framework Integration**: Installed `FlutterError.onError` and
|
||||
`ErrorWidget.builder` to catch framework-level and widget build errors,
|
||||
ensuring that all types of crashes result in a graceful error display.
|
||||
|
||||
## Optimized Android Deployment and Fixed E2E Flakiness
|
||||
|
||||
Improved the speed and reliability of the Android deployment pipeline.
|
||||
|
||||
- **Taskfile Optimization**: Updated `Taskfile.yml` to use `sources` and
|
||||
`generates` for long-running tasks. Implemented marker files (`.done` files)
|
||||
to skip `integration-android` and `deploy-android` when inputs haven't changed.
|
||||
- **E2E Reliability**: Fixed a race condition in `app_e2e_test.dart` by adding
|
||||
`pumpAndSettle()` and a 2-second safety delay before the "Save" button tap,
|
||||
resolving the intermittent "missed tap" failure on slow emulators.
|
||||
- **Deployment Confirmation**: The `deploy-android` task now verifies the build
|
||||
with a full Android integration test before uploading the APK.
|
||||
|
||||
## Coverage Gate Maintenance
|
||||
|
||||
- **Ghost Path Check**: Updated `scripts/check_coverage.dart` to verify that all
|
||||
excluded files still exist on disk, preventing "ghost paths" from cluttering
|
||||
the configuration.
|
||||
- **Increased Coverage**: Included `account_sync_manager.dart` and
|
||||
`email_repository_impl.dart` in the coverage gate.
|
||||
- **Current Status**: Total unit coverage increased to **82%**.
|
||||
|
||||
## IMAP attachments: accurate sizes and reliable downloads
|
||||
|
||||
Attachments in IMAP accounts previously showed as "0 B" in the UI because
|
||||
|
||||
@@ -9,7 +9,16 @@ abstract class EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
);
|
||||
|
||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
);
|
||||
|
||||
Future<Email?> getEmail(String emailId);
|
||||
|
||||
Future<EmailBody> getEmailBody(String emailId);
|
||||
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
||||
Future<void> setFlag(
|
||||
|
||||
@@ -2032,6 +2032,27 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<List<model.Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
) {
|
||||
return (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath) &
|
||||
t.threadId.equals(threadId),
|
||||
)
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.sentAt),
|
||||
(t) => OrderingTerm.asc(t.receivedAt),
|
||||
]))
|
||||
.watch()
|
||||
.map((rows) => rows.map(_toModel).toList());
|
||||
}
|
||||
|
||||
model.Email _toModel(Email row) {
|
||||
List<model.EmailAddress> parseAddresses(String json) {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _dateFmt = DateFormat('MMM d');
|
||||
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
||||
|
||||
class ThreadDetailScreen extends ConsumerWidget {
|
||||
const ThreadDetailScreen({
|
||||
@@ -23,74 +27,33 @@ class ThreadDetailScreen extends ConsumerWidget {
|
||||
@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) {
|
||||
appBar: AppBar(
|
||||
title: const Text('Thread'),
|
||||
),
|
||||
body: StreamBuilder<List<Email>>(
|
||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
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'));
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
final emails = snapshot.data ?? [];
|
||||
if (emails.isEmpty) {
|
||||
return const Center(child: Text('Thread not found or empty'));
|
||||
}
|
||||
// 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)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: emails.length,
|
||||
itemBuilder: (context, index) {
|
||||
final email = emails[index];
|
||||
return _EmailMessageCard(
|
||||
email: email,
|
||||
isLatest: index == emails.length - 1,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -99,3 +62,211 @@ class ThreadDetailScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailMessageCard extends ConsumerStatefulWidget {
|
||||
const _EmailMessageCard({required this.email, required this.isLatest});
|
||||
final Email email;
|
||||
final bool isLatest;
|
||||
|
||||
@override
|
||||
ConsumerState<_EmailMessageCard> createState() => _EmailMessageCardState();
|
||||
}
|
||||
|
||||
class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
late Future<EmailBody> _bodyFuture;
|
||||
bool _expanded = false;
|
||||
bool _loadRemoteImages = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bodyFuture =
|
||||
ref.read(emailRepositoryProvider).getEmailBody(widget.email.id);
|
||||
_expanded = widget.isLatest;
|
||||
if (widget.email.isSeen == false) {
|
||||
unawaited(
|
||||
ref.read(emailRepositoryProvider).setFlag(widget.email.id, seen: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
leading: CircleAvatar(
|
||||
child: Text(
|
||||
widget.email.from.isNotEmpty
|
||||
? widget.email.from.first.email[0].toUpperCase()
|
||||
: '?',
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
widget.email.from.isNotEmpty
|
||||
? widget.email.from.first.toString()
|
||||
: '(unknown)',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
widget.email.sentAt != null
|
||||
? _dateFmt.format(widget.email.sentAt!)
|
||||
: '',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.email.isFlagged)
|
||||
const Icon(Icons.star, color: Colors.amber, size: 20),
|
||||
Icon(_expanded ? Icons.expand_less : Icons.expand_more),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_expanded) _buildExpandedBody(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpandedBody() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(),
|
||||
FutureBuilder<EmailBody>(
|
||||
future: _bodyFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final body = snapshot.data!;
|
||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasHtml) ...[
|
||||
if (!_loadRemoteImages)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.image_outlined, size: 16),
|
||||
label: const Text('Load remote images'),
|
||||
onPressed: () =>
|
||||
setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
Html(
|
||||
data: body.htmlBody!,
|
||||
extensions: [
|
||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
||||
],
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
body.textBody ?? '(no body text)',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply),
|
||||
onPressed: () => _reply(context, body, replyAll: false),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: _delete,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reply(BuildContext context, EmailBody body, {required bool replyAll}) {
|
||||
final to =
|
||||
widget.email.from.isNotEmpty ? widget.email.from.first.email : '';
|
||||
final subject = (widget.email.subject?.startsWith('Re:') ?? false)
|
||||
? widget.email.subject!
|
||||
: 'Re: ${widget.email.subject ?? ''}';
|
||||
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
extra: {
|
||||
'accountId': widget.email.accountId,
|
||||
'replyToEmailId': widget.email.id,
|
||||
'prefillTo': to,
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': _quotedBody(body),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _quotedBody(EmailBody body) {
|
||||
final date = widget.email.sentAt != null
|
||||
? _dateFmt.format(widget.email.sentAt!)
|
||||
: '';
|
||||
final from = widget.email.from.isNotEmpty
|
||||
? widget.email.from.first.toString()
|
||||
: '(unknown)';
|
||||
final text = body.textBody ?? htmlToPlain(body.htmlBody ?? '');
|
||||
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete email'),
|
||||
content: const Text('Move this email to Trash?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
unawaited(ref.read(emailRepositoryProvider).deleteEmail(widget.email.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRemoteImagesExtension extends HtmlExtension {
|
||||
@override
|
||||
Set<String> get supportedTags => {'img'};
|
||||
|
||||
@override
|
||||
bool matches(ExtensionContext context) {
|
||||
if (context.elementName != 'img') return false;
|
||||
final src = context.attributes['src'] ?? '';
|
||||
return src.startsWith('http://') || src.startsWith('https://');
|
||||
}
|
||||
|
||||
@override
|
||||
InlineSpan build(ExtensionContext context) =>
|
||||
const WidgetSpan(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -20,3 +20,10 @@ Then push
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Multi-account search improvement
|
||||
|
||||
Extend the search functionality to allow searching across all accounts.
|
||||
|
||||
- **UI**: Add a search icon to the account list screen or a global search bar.
|
||||
- **Repository**: Implement `searchEmailsGlobal` to query all accounts in the database.
|
||||
- **Protocol**: For remote search, parallelize IMAP SEARCH across multiple accounts.
|
||||
|
||||
@@ -73,6 +73,10 @@ class _FakeEmails implements EmailRepository {
|
||||
Stream<List<EmailThread>> observeThreads(String a, String m) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String id) async => null;
|
||||
|
||||
|
||||
@@ -79,6 +79,14 @@ class FakeEmailRepository implements EmailRepository {
|
||||
) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => null;
|
||||
|
||||
|
||||
@@ -179,6 +179,14 @@ class FakeEmailRepository implements EmailRepository {
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
) =>
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user