722 lines
22 KiB
Dart
722 lines
22 KiB
Dart
// Shared helpers for widget tests.
|
|
//
|
|
// Each test pumps [buildApp] which wires up a fresh GoRouter (same route tree
|
|
// as the real app) inside a ProviderScope whose repository providers are
|
|
// replaced with lightweight in-memory fakes. No database or network is used.
|
|
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/models/discovery_result.dart';
|
|
import 'package:sharedinbox/core/models/draft.dart';
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
|
import 'package:sharedinbox/data/db/database.dart'
|
|
show AppDatabase, SyncHealthRow;
|
|
import 'package:sharedinbox/di.dart';
|
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/search_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake repositories
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class FakeAccountRepository implements AccountRepository {
|
|
FakeAccountRepository([List<Account>? accounts])
|
|
: _accounts = List.of(accounts ?? []);
|
|
|
|
final List<Account> _accounts;
|
|
bool hasPassword = true;
|
|
|
|
@override
|
|
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
|
|
|
|
@override
|
|
Future<Account?> getAccount(String id) async {
|
|
for (final a in _accounts) {
|
|
if (a.id == id) return a;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<void> addAccount(Account account, String password) async =>
|
|
_accounts.add(account);
|
|
|
|
@override
|
|
Future<void> updateAccount(Account account, {String? password}) async {
|
|
final idx = _accounts.indexWhere((a) => a.id == account.id);
|
|
if (idx >= 0) _accounts[idx] = account;
|
|
}
|
|
|
|
@override
|
|
Future<void> removeAccount(String id) async =>
|
|
_accounts.removeWhere((a) => a.id == id);
|
|
|
|
@override
|
|
Future<String> getPassword(String accountId) async {
|
|
if (!hasPassword) {
|
|
throw StateError('No password stored for account $accountId');
|
|
}
|
|
return 'test-password';
|
|
}
|
|
}
|
|
|
|
class FakeShareKeyRepository implements ShareKeyRepository {
|
|
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
|
|
|
|
ShareKeyMaterial? _material;
|
|
|
|
@override
|
|
Future<ShareKeyMaterial> createKeyPair() async {
|
|
_material ??= await ShareEncryptionService.generateKeyPair();
|
|
return _material!;
|
|
}
|
|
|
|
@override
|
|
Future<ShareKeyMaterial?> findByKeyId(dynamic keyId) async => _material;
|
|
}
|
|
|
|
class FakeDraftRepository implements DraftRepository {
|
|
int _nextId = 1;
|
|
final Map<int, SavedDraft> _drafts = {};
|
|
|
|
@override
|
|
Future<SavedDraft> saveDraft({
|
|
int? id,
|
|
String? accountId,
|
|
String? replyToEmailId,
|
|
required String toText,
|
|
required String ccText,
|
|
required String subjectText,
|
|
required String bodyText,
|
|
}) async {
|
|
final draftId = id ?? _nextId++;
|
|
final draft = SavedDraft(
|
|
id: draftId,
|
|
accountId: accountId,
|
|
replyToEmailId: replyToEmailId,
|
|
toText: toText,
|
|
ccText: ccText,
|
|
subjectText: subjectText,
|
|
bodyText: bodyText,
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
_drafts[draftId] = draft;
|
|
return draft;
|
|
}
|
|
|
|
@override
|
|
Future<SavedDraft?> findDraft({String? replyToEmailId}) async {
|
|
final matches = _drafts.values.where((d) {
|
|
if (replyToEmailId == null) return d.replyToEmailId == null;
|
|
return d.replyToEmailId == replyToEmailId;
|
|
}).toList()
|
|
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
|
return matches.isEmpty ? null : matches.first;
|
|
}
|
|
|
|
@override
|
|
Future<SavedDraft?> getDraft(int id) async => _drafts[id];
|
|
|
|
@override
|
|
Future<void> deleteDraft(int id) async => _drafts.remove(id);
|
|
|
|
@override
|
|
Future<void> syncDrafts(String accountId, String password) async {}
|
|
}
|
|
|
|
class FakeMailboxRepository implements MailboxRepository {
|
|
final List<Mailbox> _mailboxes;
|
|
|
|
FakeMailboxRepository([List<Mailbox>? mailboxes])
|
|
: _mailboxes = mailboxes ?? [];
|
|
|
|
@override
|
|
Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
|
|
Stream.value(List.of(_mailboxes));
|
|
|
|
@override
|
|
Future<int> syncMailboxes(String accountId) async => 0;
|
|
|
|
@override
|
|
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
|
_mailboxes.where((m) => m.role == role).firstOrNull;
|
|
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
|
|
@override
|
|
Future<Mailbox> createMailboxWithRole(
|
|
String accountId,
|
|
String name,
|
|
String role,
|
|
) async {
|
|
final mailbox = Mailbox(
|
|
id: '$accountId:$name',
|
|
accountId: accountId,
|
|
path: name,
|
|
name: name,
|
|
role: role,
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
);
|
|
_mailboxes.add(mailbox);
|
|
return mailbox;
|
|
}
|
|
}
|
|
|
|
class FakeEmailRepository implements EmailRepository {
|
|
final List<Email> _emails;
|
|
final Email? _emailDetail;
|
|
final EmailBody _emailBody;
|
|
final String _rawRfc822;
|
|
|
|
final List<Email> _searchResults;
|
|
|
|
FakeEmailRepository({
|
|
List<Email>? emails,
|
|
Email? emailDetail,
|
|
EmailBody? emailBody,
|
|
List<Email>? searchResults,
|
|
String rawRfc822 = '',
|
|
}) : _emails = emails ?? [],
|
|
_emailDetail = emailDetail,
|
|
_searchResults = searchResults ?? [],
|
|
_rawRfc822 = rawRfc822,
|
|
_emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
|
|
|
|
@override
|
|
Stream<List<Email>> observeEmails(
|
|
String accountId,
|
|
String mailboxPath, {
|
|
int limit = 50,
|
|
}) =>
|
|
Stream.value(List.of(_emails));
|
|
|
|
@override
|
|
Stream<List<EmailThread>> observeThreads(
|
|
String accountId,
|
|
String mailboxPath, {
|
|
int limit = 50,
|
|
}) =>
|
|
observeEmails(accountId, mailboxPath).map((emails) {
|
|
return emails.map((e) {
|
|
return EmailThread(
|
|
threadId: e.threadId ?? e.id,
|
|
subject: e.subject,
|
|
preview: e.preview,
|
|
participants: e.from,
|
|
latestDate: e.sentAt ?? e.receivedAt,
|
|
messageCount: 1,
|
|
hasUnread: !e.isSeen,
|
|
isFlagged: e.isFlagged,
|
|
latestEmailId: e.id,
|
|
emailIds: [e.id],
|
|
accountId: e.accountId,
|
|
mailboxPath: e.mailboxPath,
|
|
);
|
|
}).toList();
|
|
});
|
|
|
|
@override
|
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
|
Stream.value([]);
|
|
|
|
@override
|
|
Stream<List<Email>> observeEmailsInThread(
|
|
String accountId,
|
|
String mailboxPath,
|
|
String threadId,
|
|
) =>
|
|
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
|
|
|
@override
|
|
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
|
|
|
@override
|
|
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
|
|
|
@override
|
|
Future<SyncEmailsResult> syncEmails(
|
|
String accountId,
|
|
String mailboxPath,
|
|
) async =>
|
|
SyncEmailsResult.zero;
|
|
|
|
@override
|
|
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
|
|
@override
|
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
|
|
|
@override
|
|
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
|
|
|
|
@override
|
|
Future<bool> cancelPendingChange(String id, String type) async => false;
|
|
|
|
@override
|
|
Future<void> snoozeEmail(String emailId, DateTime until) async {}
|
|
|
|
@override
|
|
Future<int> wakeUpEmails(String accountId) async => 0;
|
|
|
|
@override
|
|
Future<void> restoreEmails(List<Email> emails) async {}
|
|
|
|
@override
|
|
Future<Email?> findEmailByMessageId(
|
|
String accountId,
|
|
String messageId,
|
|
) async =>
|
|
null;
|
|
|
|
@override
|
|
Future<String?> deleteEmail(String emailId) async => null;
|
|
|
|
@override
|
|
Stream<String> get onChangesQueued => const Stream.empty();
|
|
|
|
@override
|
|
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
|
|
|
@override
|
|
Future<void> sendEmail(String accountId, EmailDraft draft) async {}
|
|
|
|
@override
|
|
Future<String> downloadAttachment(
|
|
String emailId,
|
|
EmailAttachment attachment,
|
|
) async =>
|
|
'/tmp/${attachment.filename}';
|
|
|
|
@override
|
|
Future<String> fetchRawRfc822(String emailId) async => _rawRfc822;
|
|
|
|
@override
|
|
Future<List<Email>> searchEmails(
|
|
String accountId,
|
|
String mailboxPath,
|
|
String query,
|
|
) async =>
|
|
_searchResults;
|
|
|
|
@override
|
|
Future<List<Email>> searchEmailsGlobal(
|
|
String? accountId,
|
|
String query,
|
|
) async =>
|
|
_searchResults;
|
|
|
|
@override
|
|
Future<List<Email>> getEmailsByAddress(
|
|
String? accountId,
|
|
String address,
|
|
) async =>
|
|
[];
|
|
|
|
@override
|
|
Future<List<EmailAddress>> searchAddresses(
|
|
String? accountId,
|
|
String query, {
|
|
int limit = 10,
|
|
}) async =>
|
|
[];
|
|
|
|
@override
|
|
Stream<void> watchJmapPush(String accountId, String password) =>
|
|
const Stream.empty();
|
|
|
|
@override
|
|
Future<ReliabilityResult> verifySyncReliability(
|
|
String accountId,
|
|
String mailboxPath,
|
|
) async =>
|
|
ReliabilityResult.healthy;
|
|
|
|
@override
|
|
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
|
Stream.value([]);
|
|
|
|
@override
|
|
Future<void> discardMutation(int id) async {}
|
|
|
|
@override
|
|
Future<void> retryMutation(int id) async {}
|
|
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
|
|
@override
|
|
Future<int> applySieveRules(String accountId) async => 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake services
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class FakeDiscoveryService implements AccountDiscoveryService {
|
|
FakeDiscoveryService(this._result);
|
|
final DiscoveryResult _result;
|
|
|
|
@override
|
|
Future<DiscoveryResult> discover(String email) async => _result;
|
|
}
|
|
|
|
class FakeConnectionTestService implements ConnectionTestService {
|
|
FakeConnectionTestService({Exception? error}) : _error = error;
|
|
final Exception? _error;
|
|
|
|
@override
|
|
Future<String> testConnection(Account account, String password) async {
|
|
if (_error != null) throw _error;
|
|
return account.username.isNotEmpty ? account.username : account.email;
|
|
}
|
|
}
|
|
|
|
class _NoOpManageSieveProbeService implements ManageSieveProbeService {
|
|
@override
|
|
Future<void> probe(Account account) async {
|
|
/* no-op in tests */
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Builds a fully wired test app that starts at [initialLocation].
|
|
///
|
|
/// Providers are replaced with [overrides], so no database or network is used.
|
|
/// A fresh [GoRouter] is created for every call so tests are independent.
|
|
Widget buildApp({
|
|
required String initialLocation,
|
|
required List<Override> overrides,
|
|
UserPreferencesRepository? userPreferences,
|
|
ThemeMode themeMode = ThemeMode.light,
|
|
}) {
|
|
final testRouter = GoRouter(
|
|
initialLocation: initialLocation,
|
|
routes: [
|
|
GoRoute(
|
|
path: '/accounts',
|
|
builder: (ctx, state) => const AccountListScreen(),
|
|
routes: [
|
|
GoRoute(
|
|
path: 'add',
|
|
builder: (ctx, state) => const AddAccountScreen(),
|
|
),
|
|
GoRoute(
|
|
path: 'receive',
|
|
builder: (ctx, state) => const AccountReceiveScreen(),
|
|
),
|
|
GoRoute(
|
|
path: 'send',
|
|
builder: (ctx, state) => const AccountSendScreen(),
|
|
),
|
|
GoRoute(
|
|
path: 'preferences',
|
|
builder: (ctx, state) => const UserPreferencesScreen(),
|
|
),
|
|
GoRoute(
|
|
path: ':accountId/edit',
|
|
builder: (ctx, state) => EditAccountScreen(
|
|
accountId: state.pathParameters['accountId']!,
|
|
),
|
|
),
|
|
GoRoute(
|
|
path: ':accountId/search',
|
|
builder: (ctx, state) =>
|
|
SearchScreen(accountId: state.pathParameters['accountId']!),
|
|
),
|
|
GoRoute(
|
|
path: ':accountId/emails/by-address/:address',
|
|
builder: (ctx, state) => AddressEmailsScreen(
|
|
accountId: state.pathParameters['accountId']!,
|
|
address: state.pathParameters['address']!,
|
|
),
|
|
),
|
|
GoRoute(
|
|
path: ':accountId/mailboxes',
|
|
builder: (ctx, state) => MailboxListScreen(
|
|
accountId: state.pathParameters['accountId']!,
|
|
),
|
|
routes: [
|
|
GoRoute(
|
|
path: ':mailboxPath/emails',
|
|
builder: (ctx, state) => EmailListScreen(
|
|
accountId: state.pathParameters['accountId']!,
|
|
mailboxPath: state.pathParameters['mailboxPath']!,
|
|
),
|
|
routes: [
|
|
GoRoute(
|
|
path: ':emailId',
|
|
builder: (ctx, state) => EmailDetailScreen(
|
|
emailId: state.pathParameters['emailId']!,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: ':mailboxPath/threads/:threadId',
|
|
builder: (ctx, state) => ThreadDetailScreen(
|
|
accountId: state.pathParameters['accountId']!,
|
|
mailboxPath: Uri.decodeComponent(
|
|
state.pathParameters['mailboxPath']!,
|
|
),
|
|
threadId: Uri.decodeComponent(
|
|
state.pathParameters['threadId']!,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
GoRoute(
|
|
path: '/compose',
|
|
builder: (ctx, state) {
|
|
final extra = state.extra as Map<String, dynamic>?;
|
|
return ComposeScreen(
|
|
accountId: extra?['accountId'] as String?,
|
|
replyToEmailId: extra?['replyToEmailId'] as String?,
|
|
prefillTo: extra?['prefillTo'] as String?,
|
|
prefillCc: extra?['prefillCc'] as String?,
|
|
prefillSubject: extra?['prefillSubject'] as String?,
|
|
prefillBody: extra?['prefillBody'] as String?,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
|
|
return ProviderScope(
|
|
// Defaults come first so tests can override them via [overrides].
|
|
//
|
|
// syncLogRepositoryProvider is backed by a Drift StreamQuery. When the
|
|
// provider is disposed, Drift schedules a Timer.run() for cache
|
|
// debouncing. Flutter's test framework then fails the test with "A Timer
|
|
// is still pending". Replacing it with a synchronous stream avoids this.
|
|
// syncHealthProvider has the same issue and is overridden in baseOverrides.
|
|
overrides: [
|
|
dbProvider.overrideWith((ref) {
|
|
final db = AppDatabase(NativeDatabase.memory());
|
|
ref.onDispose(db.close);
|
|
return db;
|
|
}),
|
|
syncLogRepositoryProvider.overrideWithValue(
|
|
const NoOpSyncLogRepository(),
|
|
),
|
|
userPreferencesRepositoryProvider.overrideWithValue(
|
|
userPreferences ?? FakeUserPreferencesRepository(),
|
|
),
|
|
...overrides,
|
|
manageSieveProbeServiceProvider.overrideWith(
|
|
(ref) => _NoOpManageSieveProbeService(),
|
|
),
|
|
],
|
|
child: MaterialApp.router(
|
|
routerConfig: testRouter,
|
|
themeMode: themeMode,
|
|
theme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
|
useMaterial3: true,
|
|
),
|
|
darkTheme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: Colors.indigo,
|
|
brightness: Brightness.dark,
|
|
),
|
|
useMaterial3: true,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Convenience override list used by most widget tests.
|
|
///
|
|
/// Includes fakes for all repositories and the two network services so tests
|
|
/// never hit the real database, network, or IMAP server.
|
|
List<Override> baseOverrides({
|
|
List<Account>? accounts,
|
|
List<Mailbox>? mailboxes,
|
|
DiscoveryResult? discovery,
|
|
Exception? connectionError,
|
|
ShareKeyRepository? shareKeyRepository,
|
|
bool hasStoredPassword = true,
|
|
SyncHealthRow? syncHealth,
|
|
}) =>
|
|
[
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
|
),
|
|
mailboxRepositoryProvider
|
|
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
|
accountDiscoveryServiceProvider.overrideWithValue(
|
|
FakeDiscoveryService(discovery ?? UnknownDiscovery()),
|
|
),
|
|
connectionTestServiceProvider.overrideWithValue(
|
|
FakeConnectionTestService(error: connectionError),
|
|
),
|
|
shareKeyRepositoryProvider.overrideWithValue(
|
|
shareKeyRepository ?? FakeShareKeyRepository(),
|
|
),
|
|
// syncHealthProvider is backed by a Drift StreamQuery; override with a
|
|
// plain stream to avoid "A Timer is still pending" in tests.
|
|
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Common test fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const kTestAccount = Account(
|
|
id: 'acc-1',
|
|
displayName: 'Alice',
|
|
email: 'alice@example.com',
|
|
imapHost: 'imap.example.com',
|
|
smtpHost: 'smtp.example.com',
|
|
);
|
|
|
|
const kTestMailbox = Mailbox(
|
|
id: 'acc-1:INBOX',
|
|
accountId: 'acc-1',
|
|
path: 'INBOX',
|
|
name: 'INBOX',
|
|
unreadCount: 3,
|
|
totalCount: 10,
|
|
);
|
|
|
|
Email testEmail({
|
|
String id = 'acc-1:42',
|
|
String subject = 'Hello world',
|
|
bool isSeen = false,
|
|
bool isFlagged = false,
|
|
bool hasAttachment = false,
|
|
String? listUnsubscribeHeader,
|
|
}) =>
|
|
Email(
|
|
id: id,
|
|
accountId: 'acc-1',
|
|
mailboxPath: 'INBOX',
|
|
uid: 42,
|
|
subject: subject,
|
|
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: isSeen,
|
|
isFlagged: isFlagged,
|
|
hasAttachment: hasAttachment,
|
|
listUnsubscribeHeader: listUnsubscribeHeader,
|
|
);
|
|
|
|
class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
|
FakeUserPreferencesRepository({
|
|
this.menuPosition = MenuPosition.bottom,
|
|
this.mailViewButtonPosition = MenuPosition.bottom,
|
|
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
|
List<String>? trustedImageSenders,
|
|
}) : _trustedImageSenders = trustedImageSenders ?? [];
|
|
|
|
MenuPosition menuPosition;
|
|
MenuPosition mailViewButtonPosition;
|
|
AfterMailViewAction afterMailViewAction;
|
|
final List<String> _trustedImageSenders;
|
|
|
|
@override
|
|
Stream<UserPreferences> observePreferences() => Stream.value(
|
|
UserPreferences(
|
|
menuPosition: menuPosition,
|
|
mailViewButtonPosition: mailViewButtonPosition,
|
|
afterMailViewAction: afterMailViewAction,
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<void> updateMenuPosition(MenuPosition position) async {
|
|
menuPosition = position;
|
|
}
|
|
|
|
@override
|
|
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
|
|
mailViewButtonPosition = position;
|
|
}
|
|
|
|
@override
|
|
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
|
|
afterMailViewAction = action;
|
|
}
|
|
|
|
@override
|
|
Future<void> updatePrefetchMode(PrefetchMode mode) async {}
|
|
|
|
@override
|
|
Future<void> updateBodyCacheLimitMb(int mb) async {}
|
|
|
|
@override
|
|
Stream<List<String>> observeTrustedImageSenders() =>
|
|
Stream.value(List.of(_trustedImageSenders));
|
|
|
|
@override
|
|
Future<void> addTrustedImageSender(String senderEmail) async {
|
|
final normalized = senderEmail.toLowerCase();
|
|
if (!_trustedImageSenders.contains(normalized)) {
|
|
_trustedImageSenders.add(normalized);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> removeTrustedImageSender(String senderEmail) async {
|
|
_trustedImageSenders.remove(senderEmail.toLowerCase());
|
|
}
|
|
}
|
|
|
|
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
|
final List<String> _history = [];
|
|
|
|
@override
|
|
Future<List<String>> getRecentSearches() async => List.unmodifiable(_history);
|
|
|
|
@override
|
|
Future<void> saveSearch(String query) async {
|
|
_history.remove(query);
|
|
_history.insert(0, query);
|
|
if (_history.length > 10) _history.removeLast();
|
|
}
|
|
|
|
@override
|
|
Future<void> clearHistory() async => _history.clear();
|
|
}
|