diff --git a/done.md b/done.md index e9b14f0..aab0b7d 100644 --- a/done.md +++ b/done.md @@ -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 diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 2e0d644..9d5a669 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -9,7 +9,16 @@ abstract class EmailRepository { String accountId, String mailboxPath, ); + + /// Returns all emails belonging to [threadId] in [mailboxPath]. + Stream> observeEmailsInThread( + String accountId, + String mailboxPath, + String threadId, + ); + Future getEmail(String emailId); + Future getEmailBody(String emailId); Future syncEmails(String accountId, String mailboxPath); Future setFlag( diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 1750431..8e3357b 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2032,6 +2032,27 @@ class EmailRepositoryImpl implements EmailRepository { .toList(), ); + @override + Stream> 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 parseAddresses(String json) { final list = jsonDecode(json) as List; diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart index cb19439..3f194ff 100644 --- a/lib/ui/screens/thread_detail_screen.dart +++ b/lib/ui/screens/thread_detail_screen.dart @@ -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>( - stream: repo.observeThreads(accountId, mailboxPath), - builder: (ctx, snap) { - if (!snap.hasData) { + appBar: AppBar( + title: const Text('Thread'), + ), + body: StreamBuilder>( + 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>( - 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 _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( + 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 _delete() async { + final confirmed = await showDialog( + 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 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()); +} diff --git a/next.md b/next.md index b8e4475..93ce8f9 100644 --- a/next.md +++ b/next.md @@ -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. diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index 59bd017..6c71f2d 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -73,6 +73,10 @@ class _FakeEmails implements EmailRepository { Stream> observeThreads(String a, String m) => Stream.value([]); + @override + Stream> observeEmailsInThread(String a, String m, String t) => + Stream.value([]); + @override Future getEmail(String id) async => null; diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 05e6697..cf2596c 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -79,6 +79,14 @@ class FakeEmailRepository implements EmailRepository { ) => Stream.value([]); + @override + Stream> observeEmailsInThread( + String accountId, + String mailboxPath, + String threadId, + ) => + Stream.value([]); + @override Future getEmail(String emailId) async => null; diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 1740a9f..98adfbc 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -179,6 +179,14 @@ class FakeEmailRepository implements EmailRepository { }).toList(); }); + @override + Stream> observeEmailsInThread( + String accountId, + String mailboxPath, + String threadId, + ) => + Stream.value(_emails.where((e) => e.threadId == threadId).toList()); + @override Future getEmail(String emailId) async => _emailDetail;