Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003d2bf658 | ||
|
|
3bd3b25dd9 | ||
|
|
a7aaa8efe2 |
+3
-17
@@ -529,26 +529,12 @@ tasks:
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
dotenv: [".env"]
|
||||
method: timestamp
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
- android/**/*
|
||||
- pubspec.yaml
|
||||
generates:
|
||||
- build/app/outputs/bundle/release/app-release.aab
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
desc: Build, sign, and upload AAB to Play Store internal track via Dagger
|
||||
deps: [generate-changelog]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
- sops exec-env secrets.enc.yaml 'HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"'
|
||||
|
||||
deploy-android:
|
||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||
|
||||
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,15 +50,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// 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;
|
||||
// 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 +61,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
setState(() {
|
||||
_searchResults = null;
|
||||
_searchLoading = false;
|
||||
_lastSettledQuery = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -127,35 +117,18 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _runSearch(String query) async {
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = null;
|
||||
_lastSettledQuery = null;
|
||||
});
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() => _searchResults = 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) {
|
||||
return;
|
||||
}
|
||||
final generation = ++_searchGeneration;
|
||||
setState(() => _searchLoading = true);
|
||||
try {
|
||||
final results = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, q);
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_lastSettledQuery = q;
|
||||
});
|
||||
}
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||
if (mounted) setState(() => _searchResults = results);
|
||||
} finally {
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchLoading = false);
|
||||
}
|
||||
if (mounted) setState(() => _searchLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,8 +541,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))
|
||||
|
||||
@@ -371,14 +371,6 @@ 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:
|
||||
@@ -570,14 +562,6 @@ 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
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ dev_dependencies:
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
flutter_launcher_icons: ^0.14.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
|
||||
flutter_icons:
|
||||
android: "ic_launcher"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
|
||||
trap "rm -f $tmp" EXIT
|
||||
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
|
||||
|
||||
ANDROID_KEYSTORE_PATH="$tmp" \
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
|
||||
fvm flutter build appbundle --release --no-pub \
|
||||
--build-number "$(date +%s)" \
|
||||
--build-name "$(date +%y%m%d-%H%M)" \
|
||||
--dart-define="GIT_HASH=$(git rev-parse --short HEAD)" \
|
||||
| grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -104,6 +102,30 @@ void main() {
|
||||
expect(find.byIcon(Icons.star), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping search icon shows search bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submitting a search query shows "No results" when empty', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -408,230 +430,6 @@ 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<List<Email>>();
|
||||
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(
|
||||
'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 {
|
||||
|
||||
@@ -216,17 +216,12 @@ class FakeEmailRepository implements EmailRepository {
|
||||
|
||||
final List<Email> _searchResults;
|
||||
|
||||
/// Optional override: when set, [searchEmails] calls this instead of
|
||||
/// returning [_searchResults]. Useful for testing race-condition fixes.
|
||||
final Future<List<Email>> Function(String query)? onSearch;
|
||||
|
||||
FakeEmailRepository({
|
||||
List<Email>? emails,
|
||||
Email? emailDetail,
|
||||
EmailBody? emailBody,
|
||||
List<Email>? searchResults,
|
||||
String rawRfc822 = '',
|
||||
this.onSearch,
|
||||
}) : _emails = emails ?? [],
|
||||
_emailDetail = emailDetail,
|
||||
_searchResults = searchResults ?? [],
|
||||
@@ -279,15 +274,7 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> 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;
|
||||
}
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
|
||||
@override
|
||||
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
||||
@@ -353,10 +340,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async {
|
||||
if (onSearch != null) return onSearch!(query);
|
||||
return _searchResults;
|
||||
}
|
||||
) async =>
|
||||
_searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(
|
||||
@@ -580,7 +565,6 @@ Widget buildApp({
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -588,7 +572,6 @@ Widget buildApp({
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user