diff --git a/lib/ui/screens/undo_log_detail_screen.dart b/lib/ui/screens/undo_log_detail_screen.dart index d690c37..7060d6e 100644 --- a/lib/ui/screens/undo_log_detail_screen.dart +++ b/lib/ui/screens/undo_log_detail_screen.dart @@ -1,5 +1,6 @@ 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 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; @@ -93,7 +94,9 @@ class UndoLogDetailScreen extends ConsumerWidget { style: theme.textTheme.bodySmall, ), ), - ...action.originalEmails.map((email) => _EmailTile(email: email)), + ...action.originalEmails.map( + (email) => _EmailTile(email: email, accountId: action.accountId), + ), ], ), ); @@ -120,13 +123,14 @@ class _SectionHeader extends StatelessWidget { } } -class _EmailTile extends StatelessWidget { - const _EmailTile({required this.email}); +class _EmailTile extends ConsumerWidget { + const _EmailTile({required this.email, required this.accountId}); final Email email; + final String accountId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final sender = email.from.isNotEmpty ? (email.from.first.name ?? email.from.first.email) : '(Unknown Sender)'; @@ -134,6 +138,43 @@ class _EmailTile extends StatelessWidget { leading: const Icon(Icons.email_outlined), title: Text(email.subject ?? '(No Subject)'), subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openEmail(context, ref), + ); + } + + Future _openEmail(BuildContext context, WidgetRef ref) async { + final messageId = email.messageId; + final messenger = ScaffoldMessenger.of(context); + if (messageId == null) { + messenger.showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Cannot locate this email — no Message-ID.'), + ), + ); + return; + } + final found = await ref + .read(emailRepositoryProvider) + .findEmailByMessageId(accountId, messageId); + if (!context.mounted) return; + if (found == null) { + messenger.showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text( + 'Email no longer exists at its previous location. ' + 'Use Undo to restore it.', + ), + ), + ); + return; + } + context.go( + '/accounts/$accountId' + '/mailboxes/${Uri.encodeComponent(found.mailboxPath)}' + '/emails/${Uri.encodeComponent(found.id)}', ); } } diff --git a/test/widget/undo_log_detail_screen_test.dart b/test/widget/undo_log_detail_screen_test.dart new file mode 100644 index 0000000..eaa9cd9 --- /dev/null +++ b/test/widget/undo_log_detail_screen_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/models/undo_action.dart'; +import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart'; + +import 'helpers.dart'; + +// FakeEmailRepository subclass that returns a pre-configured email from +// findEmailByMessageId, so the tap handler in UndoLogDetailScreen can be +// exercised without a real database. +class _LookupEmailRepository extends FakeEmailRepository { + _LookupEmailRepository(this._lookup); + + final Email? _lookup; + + @override + Future findEmailByMessageId( + String accountId, + String messageId, + ) async => + _lookup; +} + +UndoAction _action({ + required List originalEmails, + String accountId = 'acc-1', +}) => + UndoAction( + id: 'undo-1', + accountId: accountId, + type: UndoType.move, + emailIds: originalEmails.map((e) => e.id).toList(), + sourceMailboxPath: 'INBOX', + destinationMailboxPath: 'Archive', + originalEmails: originalEmails, + timestamp: DateTime(2024, 6), + ); + +Email _emailWith({ + String id = 'acc-1:42', + String mailboxPath = 'INBOX', + String? messageId = '', +}) => + Email( + id: id, + accountId: 'acc-1', + mailboxPath: mailboxPath, + uid: 42, + subject: 'Hello world', + receivedAt: DateTime(2024, 6), + sentAt: DateTime(2024, 6), + from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], + to: const [EmailAddress(email: 'alice@example.com')], + cc: const [], + isSeen: false, + isFlagged: false, + hasAttachment: false, + messageId: messageId, + ); + +// Builds a minimal app whose initial location is the undo log detail screen +// for [action]. A placeholder email-detail route records its visit so the +// test can assert which path the tap navigated to. +Widget _buildApp({ + required UndoAction action, + required FakeEmailRepository emailRepo, + ValueNotifier? lastEmailRoute, +}) { + final router = GoRouter( + initialLocation: '/undo-detail', + routes: [ + GoRoute( + path: '/undo-detail', + builder: (ctx, state) => UndoLogDetailScreen(action: action), + ), + GoRoute( + path: '/accounts/:accountId/mailboxes/:mailboxPath/emails/:emailId', + builder: (ctx, state) { + lastEmailRoute?.value = state.uri.toString(); + return const Scaffold(body: Text('email-detail-route')); + }, + ), + ], + ); + + return ProviderScope( + overrides: [ + emailRepositoryProvider.overrideWithValue(emailRepo), + ], + child: MaterialApp.router(routerConfig: router), + ); +} + +void main() { + group('UndoLogDetailScreen email row tap', () { + testWidgets('navigates to the current location returned by lookup', ( + tester, + ) async { + // Original row recorded INBOX/42; after the move it now lives in + // Archive with a fresh UID — the lookup is what bridges that gap. + final original = _emailWith(); + final current = _emailWith(id: 'acc-1:77', mailboxPath: 'Archive'); + final lastRoute = ValueNotifier(null); + + await tester.pumpWidget( + _buildApp( + action: _action(originalEmails: [original]), + emailRepo: _LookupEmailRepository(current), + lastEmailRoute: lastRoute, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hello world')); + await tester.pumpAndSettle(); + + expect(find.text('email-detail-route'), findsOneWidget); + expect( + lastRoute.value, + '/accounts/acc-1/mailboxes/Archive/emails/acc-1%3A77', + ); + }); + + testWidgets('shows snackbar when lookup returns null', (tester) async { + final original = _emailWith(); + final lastRoute = ValueNotifier(null); + + await tester.pumpWidget( + _buildApp( + action: _action(originalEmails: [original]), + emailRepo: _LookupEmailRepository(null), + lastEmailRoute: lastRoute, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hello world')); + await tester.pump(); + + expect( + find.textContaining('Email no longer exists'), + findsOneWidget, + ); + expect(lastRoute.value, isNull); + expect(find.text('email-detail-route'), findsNothing); + }); + + testWidgets('shows snackbar when email has no Message-ID', (tester) async { + final original = _emailWith(messageId: null); + final lastRoute = ValueNotifier(null); + + await tester.pumpWidget( + _buildApp( + action: _action(originalEmails: [original]), + // Lookup would succeed if called, but with no Message-ID the + // tap handler must short-circuit before reaching it. + emailRepo: _LookupEmailRepository(_emailWith()), + lastEmailRoute: lastRoute, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hello world')); + await tester.pump(); + + expect(find.textContaining('no Message-ID'), findsOneWidget); + expect(lastRoute.value, isNull); + expect(find.text('email-detail-route'), findsNothing); + }); + }); +}