diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 3f94f86..b51d5d5 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -50,6 +50,11 @@ class _EmailListScreenState extends ConsumerState { // Pagination: number of threads currently requested from the DB. static const _pageSize = 50; int _limit = _pageSize; + + // Incremented on every search start; stale completions are ignored when the + // generation has advanced (prevents out-of-order IMAP responses from + // overwriting fresh results with results for an older query). + int _searchGeneration = 0; bool get _selecting => _selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty; @@ -121,14 +126,19 @@ class _EmailListScreenState extends ConsumerState { setState(() => _searchResults = null); return; } + final generation = ++_searchGeneration; setState(() => _searchLoading = true); try { final results = await ref .read(emailRepositoryProvider) .searchEmails(widget.accountId, widget.mailboxPath, query.trim()); - if (mounted) setState(() => _searchResults = results); + if (mounted && generation == _searchGeneration) { + setState(() => _searchResults = results); + } } finally { - if (mounted) setState(() => _searchLoading = false); + if (mounted && generation == _searchGeneration) { + setState(() => _searchLoading = false); + } } } diff --git a/pubspec.lock b/pubspec.lock index 17e8bbd..f19add9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -371,6 +371,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -562,6 +570,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" integration_test: dependency: "direct dev" description: flutter diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 85fda74..05c0633 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -430,6 +432,128 @@ void main() { expect(find.text('Result email'), findsWidgets); }); + testWidgets( + 'tapping first of multiple search results opens the first email', + (tester) async { + final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match'); + final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match'); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository( + searchResults: [email1, email2], + emailBody: const EmailBody(emailId: '', attachments: []), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Match'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + + expect(find.text('Alpha Match'), findsOneWidget); + expect(find.text('Beta Match'), findsOneWidget); + + // Tap the first result. + await tester.tap(find.text('Alpha Match')); + await tester.pumpAndSettle(); + + expect(find.byType(EmailDetailScreen), findsOneWidget); + // The detail AppBar title shows the first email's subject. + expect( + find.descendant( + of: find.byType(AppBar), + matching: find.text('Alpha Match'), + ), + findsOneWidget, + ); + // The second email's subject must not appear in the detail view. + expect( + find.descendant( + of: find.byType(EmailDetailScreen), + matching: find.text('Beta Match'), + ), + findsNothing, + ); + }, + ); + + testWidgets( + 'stale search results from a slower concurrent search are discarded', + (tester) async { + // Reproduces: user types quickly, triggering multiple concurrent IMAP + // searches. An older, slower search must not overwrite the results for + // the user's current query (issue #467). + final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result'); + final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result'); + + // The first search call is held open by a Completer; all subsequent + // calls resolve immediately with freshEmail. + final staleCompleter = Completer>(); + var firstCall = true; + + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository( + onSearch: (_) { + if (firstCall) { + firstCall = false; + return staleCompleter.future; + } + return Future.value([freshEmail]); + }, + emailBody: const EmailBody(emailId: '', attachments: []), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + // Trigger the first (slow) search. + await tester.enterText(find.byType(TextField), 'slow'); + await tester.testTextInput.receiveAction(TextInputAction.search); + // Do not pumpAndSettle yet — the slow search is still in flight. + + // Trigger the second (fast) search by changing the query. + await tester.enterText(find.byType(TextField), 'fast'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); // fast searches settle immediately + + // The fresh results must be shown. + expect(find.text('Fresh Result'), findsOneWidget); + expect(find.text('Stale Result'), findsNothing); + + // Now let the stale search complete. + staleCompleter.complete([staleEmail]); + await tester.pumpAndSettle(); + + // The stale results must NOT replace the fresh ones. + expect(find.text('Fresh Result'), findsOneWidget); + expect(find.text('Stale Result'), findsNothing); + }, + ); + testWidgets('deleting all search results pops back to previous screen', ( tester, ) async { diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index bd90316..ce6a152 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -216,12 +216,17 @@ class FakeEmailRepository implements EmailRepository { final List _searchResults; + /// Optional override: when set, [searchEmails] calls this instead of + /// returning [_searchResults]. Useful for testing race-condition fixes. + final Future> Function(String query)? onSearch; + FakeEmailRepository({ List? emails, Email? emailDetail, EmailBody? emailBody, List? searchResults, String rawRfc822 = '', + this.onSearch, }) : _emails = emails ?? [], _emailDetail = emailDetail, _searchResults = searchResults ?? [], @@ -274,7 +279,15 @@ class FakeEmailRepository implements EmailRepository { Stream.value(_emails.where((e) => e.threadId == threadId).toList()); @override - Future getEmail(String emailId) async => _emailDetail; + Future getEmail(String emailId) async { + for (final e in _searchResults) { + if (e.id == emailId) return e; + } + for (final e in _emails) { + if (e.id == emailId) return e; + } + return _emailDetail; + } @override Future getEmailBody(String emailId) async => _emailBody; @@ -340,8 +353,10 @@ class FakeEmailRepository implements EmailRepository { String accountId, String mailboxPath, String query, - ) async => - _searchResults; + ) async { + if (onSearch != null) return onSearch!(query); + return _searchResults; + } @override Future> searchEmailsGlobal(