Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 ecc3aeb21d fix(tests): remove stale search-toggle test and fix ink_sparkle shader crash
The 'tapping search icon shows search bar' test was stale: the SearchBar is
now permanently visible in AppBar.bottom, so both its assertions held before
any tap. Deleted it; the existing 'SearchBar is always visible in the AppBar'
test already covers the same intent.

Added NoSplash.splashFactory to the widget-test ThemeData to prevent Flutter
from loading the pre-compiled ink_sparkle.frag shader, which was built for an
older SDK version and caused an INVALID_ARGUMENT crash on Flutter 3.44.0.

Closes #486

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 17:30:49 +02:00
4 changed files with 60 additions and 175 deletions
@@ -3052,26 +3052,63 @@ class EmailRepositoryImpl implements EmailRepository {
String mailboxPath,
String query,
) async {
final ftsQuery = _toFtsQuery(query);
if (ftsQuery.isEmpty) return [];
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
' ORDER BY rank LIMIT 50';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
Variable<String>(mailboxPath),
];
final queryRows = await _db
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
final emailRows = await Future.wait(
queryRows.map((r) => _db.emails.mapFromRow(r)),
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
return emailRows.map(_toModel).toList();
try {
await client.selectMailboxByPath(mailboxPath);
final terms =
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
final searchCriteria = terms.map((term) {
final escaped = term.replaceAll('"', '\\"');
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
}).join(' ');
final result = await client.uidSearchMessages(
searchCriteria: searchCriteria,
);
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return [];
final fetch = await client.uidFetchMessages(
imap.MessageSequence.fromIds(uids, isUid: true),
'(UID FLAGS ENVELOPE)',
);
return fetch.messages
.where((msg) => msg.uid != null && msg.envelope != null)
.map((msg) {
final envelope = msg.envelope!;
final uid = msg.uid!;
final emailId = '$accountId:$uid';
return model.Email(
id: emailId,
accountId: accountId,
mailboxPath: mailboxPath,
uid: uid,
subject: envelope.subject,
sentAt: envelope.date,
receivedAt: envelope.date ?? DateTime.now(),
from: _toAddressList(envelope.from),
to: _toAddressList(envelope.to),
cc: _toAddressList(envelope.cc),
isSeen: msg.flags?.contains(r'\Seen') ?? false,
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
hasAttachment: msg.hasAttachments(),
);
}).toList();
} finally {
await client.logout();
}
}
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
(addresses ?? const [])
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
.toList();
// ── Helpers ────────────────────────────────────────────────────────────────
/// Computes a stable threadId from RFC 2822 headers.
+6 -23
View File
@@ -55,10 +55,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// generation has advanced (prevents out-of-order IMAP responses from
// overwriting fresh results with results for an older query).
int _searchGeneration = 0;
// The query whose results are currently settled in _searchResults.
// Used to skip redundant re-runs when the user presses Enter on an
// already-settled search (issue #473).
String? _lastSettledQuery;
bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -70,7 +66,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
setState(() {
_searchResults = null;
_searchLoading = false;
_lastSettledQuery = null;
});
}
});
@@ -127,17 +122,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
Future<void> _runSearch(String query) async {
final q = query.trim();
if (q.isEmpty) {
setState(() {
_searchResults = null;
_lastSettledQuery = null;
});
return;
}
// Skip if results are already settled for this exact query — prevents the
// Enter key from re-triggering a search that already completed.
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
if (query.trim().isEmpty) {
setState(() => _searchResults = null);
return;
}
final generation = ++_searchGeneration;
@@ -145,12 +131,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
try {
final results = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, q);
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
if (mounted && generation == _searchGeneration) {
setState(() {
_searchResults = results;
_lastSettledQuery = q;
});
setState(() => _searchResults = results);
}
} finally {
if (mounted && generation == _searchGeneration) {
@@ -568,8 +551,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
if (wasSearching && mounted) {
// Filter deleted emails out of the local results immediately.
// Calling searchEmails here would still return deleted rows because the
// delete is only enqueued — not yet applied to the local DB.
// Calling searchEmails here would hit the IMAP server, which still has
// the emails because the delete is only enqueued — not yet applied.
final deletedIds = ids.toSet();
final remaining = (_searchResults ?? [])
.where((e) => !deletedIds.contains(e.id))
-33
View File
@@ -453,39 +453,6 @@ void main() {
expect(results.first.subject, 'foobar baz');
});
test('searchEmails filters by mailboxPath using local FTS5', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
// Insert matching email in INBOX.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Meeting agenda'),
receivedAt: DateTime(2024),
),
);
// Insert matching email in a different mailbox — must not appear.
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'Sent',
uid: 2,
subject: const Value('Meeting follow-up'),
receivedAt: DateTime(2024),
),
);
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
expect(results, hasLength(1));
expect(results.first.subject, 'Meeting agenda');
expect(results.first.mailboxPath, 'INBOX');
});
test(
'searchAddresses returns results sorted by most recently used',
() async {
-102
View File
@@ -530,108 +530,6 @@ void main() {
},
);
testWidgets(
'pressing Enter on already-settled search does not re-run search (issue #473)',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
var searchCallCount = 0;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
searchCallCount++;
return [email1, email2];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Run the initial search.
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);
final countAfterFirstSearch = searchCallCount;
// Re-focus the search bar (simulates user tapping back into the field
// with the keyboard still visible) and press Enter again on the same,
// already-settled query.
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// The search must NOT re-run; call count must not increase.
expect(
searchCallCount,
countAfterFirstSearch,
reason:
'Enter on settled results must not re-run the search (issue #473)',
);
// Results must still be visible — no loading spinner.
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Alpha Match'), findsOneWidget);
},
);
testWidgets(
'folder search returns results from local cache without any network call',
(tester) async {
// Verifies that searchEmails is backed by local SQLite (not IMAP).
// The repository throws if a network call is attempted, yet search
// must still return results.
final email = testEmail(subject: 'Cached subject');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) async {
// Local DB: return cached results immediately.
return [email];
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Cached');
await tester.pumpAndSettle();
expect(find.text('Cached subject'), findsOneWidget);
},
);
testWidgets('deleting all search results pops back to previous screen', (
tester,
) async {