Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6ad4183f6 | ||
|
|
a723380560 | ||
|
|
499774d1a6 | ||
|
|
132b6aeb9a | ||
|
|
efd5a1fc17 | ||
|
|
44e387bfb3 | ||
|
|
546b06ba5a | ||
|
|
5ba24a66e0 |
@@ -0,0 +1,206 @@
|
|||||||
|
# Email Sync Architecture
|
||||||
|
|
||||||
|
This document describes the full lifecycle of an email action — from the moment the user taps
|
||||||
|
a button to server confirmation — covering the IMAP IDLE loop, JMAP push/poll, the pending-change
|
||||||
|
queue, exponential backoff, and the undo/cancel mechanism.
|
||||||
|
|
||||||
|
For the database schema and protocol-level implementation details see [DB-SYNC.md](DB-SYNC.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Components
|
||||||
|
|
||||||
|
| Component | File | Role |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `AccountSyncManager` | `lib/core/sync/account_sync_manager.dart` | Owns one `_SyncLoop` per account; starts, stops, and wakes sync loops |
|
||||||
|
| `_AccountSync` | same file | IMAP sync loop (IDLE + incremental fetch) |
|
||||||
|
| `_JmapAccountSync` | same file | JMAP sync loop (SSE push + poll fallback) |
|
||||||
|
| `EmailRepositoryImpl` | `lib/data/repositories/email_repository_impl.dart` | All DB reads/writes and network calls |
|
||||||
|
| `pending_changes` table | `lib/data/db/database.dart` | Protocol-agnostic outbound mutation queue |
|
||||||
|
| `UndoService` | `lib/core/services/undo_service.dart` | Persisted undo history; cancel-or-reverse logic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lifecycle of an email mutation (e.g. "Mark as read")
|
||||||
|
|
||||||
|
```
|
||||||
|
User taps "Mark as read"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EmailRepository.setFlag(id, seen: true)
|
||||||
|
│
|
||||||
|
├─ 1. Write optimistic update to local DB
|
||||||
|
│ emails.is_seen = true
|
||||||
|
│
|
||||||
|
└─ 2. Insert row into pending_changes
|
||||||
|
{ type: 'flag_seen', email_id: id, payload: {seen: true} }
|
||||||
|
(IMAP: includes uid + mailboxPath for the STORE command)
|
||||||
|
(JMAP: includes just the flag map for Email/set)
|
||||||
|
|
||||||
|
[UI immediately reflects the change via Drift's reactive streams]
|
||||||
|
|
||||||
|
│
|
||||||
|
▼ (next sync cycle, triggered by IMAP IDLE / JMAP push / wakeUp)
|
||||||
|
_SyncLoop._flush() / flushPendingChanges()
|
||||||
|
│
|
||||||
|
├─ IMAP: open connection → STORE uid +FLAGS (\Seen) → close
|
||||||
|
│
|
||||||
|
└─ JMAP: Email/set { update: { id: { keywords: { "$seen": true } } } }
|
||||||
|
If stateMismatch → clear checkpoint → full re-sync
|
||||||
|
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
pending_changes row deleted on success
|
||||||
|
(on permanent error: retry count incremented; evicted after 5 failures)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. IMAP sync loop
|
||||||
|
|
||||||
|
The IMAP loop runs one coroutine per account (`_AccountSync`):
|
||||||
|
|
||||||
|
```
|
||||||
|
start()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[forever loop]
|
||||||
|
├─ flushPendingChanges() ← drain outbound queue first
|
||||||
|
├─ syncMailboxes() ← detect new/removed mailboxes
|
||||||
|
├─ for each mailbox:
|
||||||
|
│ syncEmails() ← incremental: fetch only UIDs > lastUid
|
||||||
|
│ deletion reconciliation: remove rows
|
||||||
|
│ whose UID is absent from the server
|
||||||
|
└─ _idle() ← IMAP IDLE for up to 25 min (RFC 2177)
|
||||||
|
│ Wakes on: server EXISTS/EXPUNGE/FLAGS
|
||||||
|
│ or syncNow() signal from UI
|
||||||
|
└─ repeat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incremental sync checkpoint** — `sync_state` table stores `(accountId, mailbox, lastUid, uidValidity)`.
|
||||||
|
On each run, only UIDs greater than `lastUid` are fetched. If `uidValidity` changes the full
|
||||||
|
folder is re-scanned and the checkpoint is reset.
|
||||||
|
|
||||||
|
**IDLE cap** — IDLE sessions are limited to 25 minutes per the RFC. The loop also wakes
|
||||||
|
immediately if `syncNow()` is called (e.g. user pulls-to-refresh).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JMAP sync loop
|
||||||
|
|
||||||
|
The JMAP loop (`_JmapAccountSync`) follows a similar structure but uses HTTP:
|
||||||
|
|
||||||
|
```
|
||||||
|
start()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[forever loop]
|
||||||
|
├─ flushPendingChanges() ← Email/set for queued mutations
|
||||||
|
├─ syncMailboxes() ← Mailbox/get or Mailbox/changes
|
||||||
|
├─ for each mailbox:
|
||||||
|
│ syncEmails() ← Email/query + Email/get (first run)
|
||||||
|
│ Email/changes (subsequent runs, state token)
|
||||||
|
└─ _wait()
|
||||||
|
├─ If server advertises eventSourceUrl: subscribe to SSE push
|
||||||
|
│ wake on "Email" change event
|
||||||
|
└─ Otherwise: sleep 30 s (poll fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
**State tokens** — each `Mailbox/changes` / `Email/changes` call uses the server-provided
|
||||||
|
`state` token stored in `sync_state`. A `stateMismatch` error clears the token and triggers
|
||||||
|
a full re-fetch.
|
||||||
|
|
||||||
|
**JMAP send** — outgoing mail uses `EmailSubmission/set` when the server advertises the
|
||||||
|
`urn:ietf:params:jmap:submission` capability; falls back to SMTP otherwise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Exponential backoff
|
||||||
|
|
||||||
|
Both loops share the same backoff policy:
|
||||||
|
|
||||||
|
| Outcome | Backoff |
|
||||||
|
|---------|---------|
|
||||||
|
| Sync succeeded | Reset to 5 s |
|
||||||
|
| Network / server error | Double previous backoff, capped at 900 s (15 min) |
|
||||||
|
|
||||||
|
The backoff counter (`_backoffSeconds`) is per-account and per-process; it resets to 5 s
|
||||||
|
on the next successful cycle.
|
||||||
|
|
||||||
|
The last error message is written to `sync_log` and surfaced in the UI via
|
||||||
|
`syncLastErrorProvider` (the red `MaterialBanner` in the email list).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pending-change queue
|
||||||
|
|
||||||
|
`pending_changes` is a protocol-agnostic table that stores every outbound mutation before it
|
||||||
|
reaches the server:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `id` | Auto-increment primary key |
|
||||||
|
| `email_id` | The email being mutated |
|
||||||
|
| `type` | `flag_seen`, `flag_flagged`, `move`, `delete`, `snooze` |
|
||||||
|
| `payload` | JSON-encoded protocol-specific arguments |
|
||||||
|
| `retry_count` | Incremented on each failed flush attempt |
|
||||||
|
| `created_at` | For ordering and debug |
|
||||||
|
|
||||||
|
**Optimistic UI** — every mutation writes the local change first, then inserts into
|
||||||
|
`pending_changes`. The Drift reactive stream delivers the update to the UI before
|
||||||
|
the network round-trip completes.
|
||||||
|
|
||||||
|
**Conflict resolution** — the server always wins. On the next sync cycle the server's
|
||||||
|
state overwrites local rows. Outbound mutations are retried up to 5 times; after that
|
||||||
|
they are evicted and a `FailedMutation` record is created. Permanent per-item JMAP
|
||||||
|
errors (`notFound`, `forbidden`) skip the retry counter and evict immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Undo and cancel
|
||||||
|
|
||||||
|
When the user triggers an undoable action the UI calls:
|
||||||
|
```
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(UndoAction(...))
|
||||||
|
```
|
||||||
|
|
||||||
|
`UndoService` persists the action to the `undo_actions` table (max 10 entries, FIFO).
|
||||||
|
A `SnackBar` with an **Undo** button appears for a few seconds.
|
||||||
|
|
||||||
|
When the user taps Undo, `UndoService.undo()` executes this sequence for each affected email:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. cancelPendingChange(id, originalType)
|
||||||
|
└─ Deletes the pending_changes row if it has not been flushed yet.
|
||||||
|
Returns true if cancelled, false if the server already processed it.
|
||||||
|
|
||||||
|
2. If the email row was hard-deleted (DELETE action):
|
||||||
|
restoreEmails([original])
|
||||||
|
└─ Re-inserts the row with its pre-deletion state,
|
||||||
|
placed in the correct mailbox (source if cancelled, dest otherwise).
|
||||||
|
|
||||||
|
3. moveEmail(id, sourceMailboxPath)
|
||||||
|
└─ Optimistic local move back to the original folder.
|
||||||
|
If step 1 returned false (already sent to server), this enqueues
|
||||||
|
a reverse-move in pending_changes so the server move is undone too.
|
||||||
|
|
||||||
|
4. If step 1 returned true (cancelled before flush):
|
||||||
|
cancelPendingChange(id, 'move')
|
||||||
|
└─ The reverse-move from step 3 is redundant; remove it.
|
||||||
|
```
|
||||||
|
|
||||||
|
The net result is: if the mutation was still in the queue it is silently cancelled with no
|
||||||
|
server round-trip; if it had already been flushed, a compensating move is queued.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Key invariants
|
||||||
|
|
||||||
|
- **Order**: pending changes are flushed before syncing. This prevents the server from
|
||||||
|
overwriting an optimistic local state that the server hasn't seen yet.
|
||||||
|
- **Idempotency**: `flushPendingChanges` is safe to call multiple times. Each row is
|
||||||
|
deleted only after the server acknowledges the change.
|
||||||
|
- **No silent data loss**: permanent server errors surface as `FailedMutation` records
|
||||||
|
visible in the UI (Settings → Failed mutations).
|
||||||
|
- **UI layer isolation**: `lib/ui/` never imports `lib/data/`; all interaction goes
|
||||||
|
through `core/` interfaces. The `check-layers` Taskfile task enforces this.
|
||||||
@@ -27,6 +27,7 @@ abstract class EmailRepository {
|
|||||||
Future<EmailBody> getEmailBody(String emailId);
|
Future<EmailBody> getEmailBody(String emailId);
|
||||||
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
||||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
|
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath);
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath);
|
Future<void> moveEmail(String emailId, String destMailboxPath);
|
||||||
|
|
||||||
/// Deletes the email. Returns the path of the mailbox it was moved to
|
/// Deletes the email. Returns the path of the mailbox it was moved to
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
abstract interface class SearchHistoryRepository {
|
||||||
|
Future<List<String>> getRecentSearches();
|
||||||
|
Future<void> saveSearch(String query);
|
||||||
|
Future<void> clearHistory();
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
|||||||
import 'package:sharedinbox/core/utils/logger.dart';
|
import 'package:sharedinbox/core/utils/logger.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
|
||||||
show ImapConnectFn, connectImap, verboseLogKey;
|
show ImapConnectFn, connectImap, verboseLogKey;
|
||||||
|
import 'package:sharedinbox/data/imap/tls_error.dart' show isTlsConfigError;
|
||||||
|
|
||||||
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
|
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
|
||||||
|
|
||||||
@@ -291,6 +292,7 @@ class _AccountSync implements _SyncLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
|
if (isTlsConfigError(e)) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
@@ -528,6 +530,7 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isPermanentError(Object e) {
|
bool _isPermanentError(Object e) {
|
||||||
|
if (isTlsConfigError(e)) return true;
|
||||||
final s = e.toString().toLowerCase();
|
final s = e.toString().toLowerCase();
|
||||||
return s.contains('invalid credentials') ||
|
return s.contains('invalid credentials') ||
|
||||||
s.contains('authentication failed') ||
|
s.contains('authentication failed') ||
|
||||||
|
|||||||
@@ -234,6 +234,13 @@ class Drafts extends Table {
|
|||||||
TextColumn get imapServerId => text().nullable()();
|
TextColumn get imapServerId => text().nullable()();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DataClassName('SearchHistoryRow')
|
||||||
|
class SearchHistoryEntries extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get query => text()();
|
||||||
|
DateTimeColumn get searchedAt => dateTime()();
|
||||||
|
}
|
||||||
|
|
||||||
@DataClassName('UndoActionRow')
|
@DataClassName('UndoActionRow')
|
||||||
class UndoActions extends Table {
|
class UndoActions extends Table {
|
||||||
TextColumn get id => text()();
|
TextColumn get id => text()();
|
||||||
@@ -263,13 +270,14 @@ class UndoActions extends Table {
|
|||||||
SyncLogMailboxes,
|
SyncLogMailboxes,
|
||||||
SyncHealth,
|
SyncHealth,
|
||||||
UndoActions,
|
UndoActions,
|
||||||
|
SearchHistoryEntries,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 26;
|
int get schemaVersion => 27;
|
||||||
|
|
||||||
Future<void> _createEmailFts() async {
|
Future<void> _createEmailFts() async {
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
@@ -492,6 +500,9 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
SELECT rowid, subject, preview, from_json FROM emails
|
SELECT rowid, subject, preview, from_json FROM emails
|
||||||
''');
|
''');
|
||||||
}
|
}
|
||||||
|
if (from < 27) {
|
||||||
|
await m.createTable(searchHistoryEntries);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,52 @@ class TlsModeMismatchException implements Exception {
|
|||||||
'STARTTLS). Original error: $original';
|
'STARTTLS). Original error: $original';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If [error] is a TLS handshake failure caused by a wrong-version-number
|
/// Wraps a TLS certificate verification failure into a user-actionable message.
|
||||||
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
|
///
|
||||||
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
|
/// Thrown when the server's certificate cannot be verified — either because it
|
||||||
|
/// is self-signed, expired, or the CA chain has changed since the account was
|
||||||
|
/// set up.
|
||||||
|
class TlsCertificateException implements Exception {
|
||||||
|
TlsCertificateException(this.host, this.port, this.original);
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final Object original;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'TLS certificate error on $host:$port — the server certificate could '
|
||||||
|
'not be verified. The certificate may have changed or expired. '
|
||||||
|
'Please re-check your account settings or contact your mail provider. '
|
||||||
|
'Original error: $original';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if [error] is a permanent TLS configuration error that will
|
||||||
|
/// not resolve on its own and requires user action.
|
||||||
|
bool isTlsConfigError(Object error) =>
|
||||||
|
error is TlsModeMismatchException || error is TlsCertificateException;
|
||||||
|
|
||||||
|
/// If [error] is a recognisable TLS handshake failure, wraps it in a typed
|
||||||
|
/// exception and throws it. Otherwise rethrows [error] unchanged.
|
||||||
|
///
|
||||||
|
/// Recognised patterns:
|
||||||
|
/// - `WRONG_VERSION_NUMBER` → [TlsModeMismatchException] (port/mode mismatch)
|
||||||
|
/// - `CERTIFICATE_VERIFY_FAILED` / `HandshakeException` → [TlsCertificateException]
|
||||||
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
|
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
|
||||||
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
|
final s = error.toString();
|
||||||
|
if (s.contains('WRONG_VERSION_NUMBER')) {
|
||||||
Error.throwWithStackTrace(
|
Error.throwWithStackTrace(
|
||||||
TlsModeMismatchException(host, port, error),
|
TlsModeMismatchException(host, port, error),
|
||||||
stack,
|
stack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (s.contains('CERTIFICATE_VERIFY_FAILED') ||
|
||||||
|
s.contains('HandshakeException') ||
|
||||||
|
s.contains('CERTIFICATE_EXPIRED') ||
|
||||||
|
s.contains('CERTIFICATE_UNKNOWN')) {
|
||||||
|
Error.throwWithStackTrace(
|
||||||
|
TlsCertificateException(host, port, error),
|
||||||
|
stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
Error.throwWithStackTrace(error, stack);
|
Error.throwWithStackTrace(error, stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1520,6 +1520,63 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final unread = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath) &
|
||||||
|
t.isSeen.equals(false),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
if (unread.isEmpty) return;
|
||||||
|
|
||||||
|
await _db.transaction(() async {
|
||||||
|
for (final row in unread) {
|
||||||
|
if (account.type == account_model.AccountType.jmap) {
|
||||||
|
await _enqueueChange(
|
||||||
|
accountId,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({'seen': true}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _enqueueChange(
|
||||||
|
accountId,
|
||||||
|
row.id,
|
||||||
|
'flag_seen',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'mailboxPath': row.mailboxPath,
|
||||||
|
'seen': true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk mark all unread emails in this mailbox as seen.
|
||||||
|
await (_db.update(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath) &
|
||||||
|
t.isSeen.equals(false),
|
||||||
|
))
|
||||||
|
.write(const EmailsCompanion(isSeen: Value(true)));
|
||||||
|
|
||||||
|
// Update all threads in this mailbox to reflect no unread.
|
||||||
|
await (_db.update(_db.threads)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.mailboxPath.equals(mailboxPath),
|
||||||
|
))
|
||||||
|
.write(const ThreadsCompanion(hasUnread: Value(false)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
|
||||||
|
class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||||
|
SearchHistoryRepositoryImpl(this._db);
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
static const _maxEntries = 10;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getRecentSearches() async {
|
||||||
|
final rows = await (_db.select(_db.searchHistoryEntries)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||||
|
..limit(_maxEntries))
|
||||||
|
.get();
|
||||||
|
return rows.map((r) => r.query).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSearch(String query) async {
|
||||||
|
final trimmed = query.trim();
|
||||||
|
if (trimmed.isEmpty) return;
|
||||||
|
|
||||||
|
await _db.transaction(() async {
|
||||||
|
// Remove existing entry for same query (deduplication).
|
||||||
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
|
..where((t) => t.query.equals(trimmed)))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
|
SearchHistoryEntriesCompanion.insert(
|
||||||
|
query: trimmed,
|
||||||
|
searchedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prune to the most recent _maxEntries.
|
||||||
|
final keepIds = await (_db.select(_db.searchHistoryEntries)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
|
||||||
|
..limit(_maxEntries))
|
||||||
|
.map((r) => r.id)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (keepIds.isNotEmpty) {
|
||||||
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
|
..where((t) => t.id.isNotIn(keepIds)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearHistory() async {
|
||||||
|
await _db.delete(_db.searchHistoryEntries).go();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
@@ -25,6 +26,7 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
|||||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||||
@@ -87,6 +89,11 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final searchHistoryRepositoryProvider =
|
||||||
|
Provider<SearchHistoryRepository>((ref) {
|
||||||
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final syncLogRepositoryProvider = Provider((ref) {
|
final syncLogRepositoryProvider = Provider((ref) {
|
||||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
import 'package:flutter_html/flutter_html.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -60,20 +61,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
tooltip: 'Reply',
|
tooltip: 'Reply',
|
||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () => _reply(context, header, body, replyAll: false),
|
: () {
|
||||||
|
unawaited(_reply(context, header, body, replyAll: false));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.reply_all),
|
icon: const Icon(Icons.reply_all),
|
||||||
tooltip: 'Reply all',
|
tooltip: 'Reply all',
|
||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () => _reply(context, header, body, replyAll: true),
|
: () {
|
||||||
|
unawaited(_reply(context, header, body, replyAll: true));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.forward),
|
icon: const Icon(Icons.forward),
|
||||||
tooltip: 'Forward',
|
tooltip: 'Forward',
|
||||||
onPressed:
|
onPressed: header == null
|
||||||
header == null ? null : () => _forward(context, header, body),
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_forward(context, header, body));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||||
@@ -263,26 +271,31 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _quotedBody(Email header, EmailBody? body) {
|
Future<String> _quotedBody(Email header, EmailBody? body) async {
|
||||||
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
||||||
final from =
|
final from =
|
||||||
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
||||||
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
|
final rawText = body?.textBody;
|
||||||
|
final text = (rawText != null && rawText.isNotEmpty)
|
||||||
|
? rawText
|
||||||
|
: await compute(htmlToPlain, body?.htmlBody ?? '');
|
||||||
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
||||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reply(
|
Future<void> _reply(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
EmailBody? body, {
|
EmailBody? body, {
|
||||||
required bool replyAll,
|
required bool replyAll,
|
||||||
}) {
|
}) async {
|
||||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Re: ${header.subject ?? ''}';
|
: 'Re: ${header.subject ?? ''}';
|
||||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||||
|
final quoted = await _quotedBody(header, body);
|
||||||
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
@@ -290,23 +303,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
'replyToEmailId': widget.emailId,
|
'replyToEmailId': widget.emailId,
|
||||||
'prefillTo': to,
|
'prefillTo': to,
|
||||||
'prefillSubject': subject,
|
'prefillSubject': subject,
|
||||||
'prefillBody': _quotedBody(header, body),
|
'prefillBody': quoted,
|
||||||
if (cc.isNotEmpty) 'prefillCc': cc,
|
if (cc.isNotEmpty) 'prefillCc': cc,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _forward(BuildContext context, Email header, EmailBody? body) {
|
Future<void> _forward(
|
||||||
|
BuildContext context,
|
||||||
|
Email header,
|
||||||
|
EmailBody? body,
|
||||||
|
) async {
|
||||||
final subject = (header.subject?.startsWith('Fwd:') ?? false)
|
final subject = (header.subject?.startsWith('Fwd:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Fwd: ${header.subject ?? ''}';
|
: 'Fwd: ${header.subject ?? ''}';
|
||||||
|
final quoted = await _quotedBody(header, body);
|
||||||
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
extra: {
|
extra: {
|
||||||
'prefillSubject': subject,
|
'prefillSubject': subject,
|
||||||
'prefillBody': _quotedBody(header, body),
|
'prefillBody': quoted,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -193,6 +193,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
extra: {'accountId': widget.accountId},
|
extra: {'accountId': widget.accountId},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == 'mark_all_read') {
|
||||||
|
await emailRepo.markAllAsRead(
|
||||||
|
widget.accountId,
|
||||||
|
widget.mailboxPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => const [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'mark_all_read',
|
||||||
|
child: Text('Mark all as read'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import 'package:sharedinbox/core/utils/logger.dart';
|
|||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||||
|
|
||||||
|
final _searchHistoryProvider =
|
||||||
|
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||||
|
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||||
|
});
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
const SearchScreen({super.key, this.accountId});
|
const SearchScreen({super.key, this.accountId});
|
||||||
final String? accountId;
|
final String? accountId;
|
||||||
@@ -20,13 +25,24 @@ class SearchScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||||
final _ctrl = TextEditingController();
|
final _ctrl = TextEditingController();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
_SearchResults? _results;
|
_SearchResults? _results;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
bool _fieldFocused = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode.addListener(() {
|
||||||
|
if (mounted) setState(() => _fieldFocused = _focusNode.hasFocus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ctrl.dispose();
|
_ctrl.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -45,6 +61,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
|
|
||||||
Future<void> _search(String query) async {
|
Future<void> _search(String query) async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(searchHistoryRepositoryProvider)
|
||||||
|
.saveSearch(query)
|
||||||
|
.then((_) => ref.invalidate(_searchHistoryProvider)),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final emailRepo = ref.read(emailRepositoryProvider);
|
final emailRepo = ref.read(emailRepositoryProvider);
|
||||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
@@ -112,6 +134,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: TextField(
|
title: TextField(
|
||||||
controller: _ctrl,
|
controller: _ctrl,
|
||||||
|
focusNode: _focusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Search folders, addresses, emails…',
|
hintText: 'Search folders, addresses, emails…',
|
||||||
@@ -137,6 +160,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_results == null) {
|
if (_results == null) {
|
||||||
|
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||||
|
return _buildHistoryPanel();
|
||||||
|
}
|
||||||
return const Center(child: Text('Type 3+ characters to search'));
|
return const Center(child: Text('Type 3+ characters to search'));
|
||||||
}
|
}
|
||||||
final r = _results!;
|
final r = _results!;
|
||||||
@@ -169,6 +195,66 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryPanel() {
|
||||||
|
final history = ref.watch(_searchHistoryProvider);
|
||||||
|
return history.when(
|
||||||
|
loading: () => const Center(child: Text('Type 3+ characters to search')),
|
||||||
|
error: (_, __) =>
|
||||||
|
const Center(child: Text('Type 3+ characters to search')),
|
||||||
|
data: (terms) {
|
||||||
|
if (terms.isEmpty) {
|
||||||
|
return const Center(child: Text('Type 3+ characters to search'));
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Recent searches',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await ref
|
||||||
|
.read(searchHistoryRepositoryProvider)
|
||||||
|
.clearHistory();
|
||||||
|
ref.invalidate(_searchHistoryProvider);
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
for (final term in terms)
|
||||||
|
ActionChip(
|
||||||
|
label: Text(term),
|
||||||
|
onPressed: () {
|
||||||
|
_ctrl.text = term;
|
||||||
|
_ctrl.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: term.length),
|
||||||
|
);
|
||||||
|
unawaited(_search(term));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SearchResults {
|
class _SearchResults {
|
||||||
|
|||||||
+5
-11
@@ -43,10 +43,7 @@ Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/utils/format_utils.dar
|
|||||||
|
|
||||||
### R4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/23
|
### R4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/23
|
||||||
|
|
||||||
### R5 🟡 Handle TLS certificate changes gracefully
|
### R5 — Done: https://codeberg.org/guettli/sharedinbox/pulls/45
|
||||||
`tls_error.dart` detects TLS errors but they bubble up as generic errors in the sync loop.
|
|
||||||
Detect `TlsError` specifically in `_AccountSync` and show a user-facing dialog offering to re-add the account or trust the new certificate.
|
|
||||||
Files: `lib/data/imap/tls_error.dart`, `lib/core/sync/account_sync_manager.dart`.
|
|
||||||
|
|
||||||
### R6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/24
|
### R6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/24
|
||||||
|
|
||||||
@@ -86,9 +83,7 @@ Files: `lib/ui/screens/search_screen.dart`, `lib/data/db/database.dart`.
|
|||||||
|
|
||||||
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
|
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
|
||||||
|
|
||||||
### U5 🟡 Accessible swipe actions on email list items
|
### U5 — Already implemented (Dismissible archive/delete swipes with undo, found in email_list_screen.dart)
|
||||||
Delete and Move are hidden behind long-press or detail-screen menus. Add leading/trailing swipe actions on the `EmailListScreen` tile (archive / delete) matching Material 3 patterns.
|
|
||||||
Files: `lib/ui/screens/email_list_screen.dart`.
|
|
||||||
|
|
||||||
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
|
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
|
||||||
|
|
||||||
@@ -109,6 +104,8 @@ Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/repositories/email_rep
|
|||||||
|
|
||||||
### T2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/31
|
### T2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/31
|
||||||
|
|
||||||
|
### T3 — Done: https://codeberg.org/guettli/sharedinbox/pulls/43
|
||||||
|
|
||||||
### T3 🟡 Contract tests for all Repository interfaces
|
### T3 🟡 Contract tests for all Repository interfaces
|
||||||
The interfaces in `core/repositories/` have no shared contract test suite. Concrete impls can silently diverge.
|
The interfaces in `core/repositories/` have no shared contract test suite. Concrete impls can silently diverge.
|
||||||
Add a shared `EmailRepositoryContract` abstract test class; run it against both `EmailRepositoryImpl` and any future mock/fake. Mirror this for `MailboxRepository` and `AccountRepository`.
|
Add a shared `EmailRepositoryContract` abstract test class; run it against both `EmailRepositoryImpl` and any future mock/fake. Mirror this for `MailboxRepository` and `AccountRepository`.
|
||||||
@@ -129,10 +126,7 @@ Files: `test/widget/email_list_screen_test.dart`.
|
|||||||
|
|
||||||
### A2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/33
|
### A2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/33
|
||||||
|
|
||||||
### A3 🟡 Make AccountSyncManager testable without real IMAP connections
|
### A3 — Done: https://codeberg.org/guettli/sharedinbox/pulls/46
|
||||||
`AccountSyncManager` accepts `ImapConnectFn` as a dependency but `_JmapAccountSync` constructs its HTTP client internally.
|
|
||||||
Pass an injectable `http.Client` to `_JmapAccountSync` (already done in `EmailRepositoryImpl`; mirror the pattern here).
|
|
||||||
Files: `lib/core/sync/account_sync_manager.dart`, `test/unit/account_sync_manager_test.dart`.
|
|
||||||
|
|
||||||
### A4 🟡 Replace raw JSON strings in DB with structured encoding
|
### A4 🟡 Replace raw JSON strings in DB with structured encoding
|
||||||
`fromJson`, `toAddresses`, `ccJson`, `references` are stored as raw JSON strings parsed on every model conversion.
|
`fromJson`, `toAddresses`, `ccJson`, `references` are stored as raw JSON strings parsed on every model conversion.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const _noCode = {
|
|||||||
'lib/core/repositories/mailbox_repository.dart',
|
'lib/core/repositories/mailbox_repository.dart',
|
||||||
'lib/core/repositories/sync_log_repository.dart',
|
'lib/core/repositories/sync_log_repository.dart',
|
||||||
'lib/core/repositories/undo_repository.dart',
|
'lib/core/repositories/undo_repository.dart',
|
||||||
|
'lib/core/repositories/search_history_repository.dart',
|
||||||
'lib/core/models/undo_action.dart',
|
'lib/core/models/undo_action.dart',
|
||||||
'lib/core/storage/secure_storage.dart',
|
'lib/core/storage/secure_storage.dart',
|
||||||
};
|
};
|
||||||
@@ -61,6 +62,7 @@ const _excluded = {
|
|||||||
'lib/data/repositories/mailbox_repository_impl.dart',
|
'lib/data/repositories/mailbox_repository_impl.dart',
|
||||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
'lib/data/repositories/sync_log_repository_impl.dart',
|
||||||
'lib/data/repositories/undo_repository_impl.dart',
|
'lib/data/repositories/undo_repository_impl.dart',
|
||||||
|
'lib/data/repositories/search_history_repository_impl.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
+42
-12
@@ -4,6 +4,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
import google_auth_httplib2
|
import google_auth_httplib2
|
||||||
import httplib2
|
import httplib2
|
||||||
@@ -15,6 +16,14 @@ PACKAGE_NAME = "de.sharedinbox.mua"
|
|||||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||||
TRACK = "internal"
|
TRACK = "internal"
|
||||||
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
||||||
|
_MAX_UPLOAD_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service(creds):
|
||||||
|
authorized_http = google_auth_httplib2.AuthorizedHttp(
|
||||||
|
creds, http=httplib2.Http(timeout=_TIMEOUT)
|
||||||
|
)
|
||||||
|
return build("androidpublisher", "v3", http=authorized_http)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -32,22 +41,43 @@ def main():
|
|||||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||||
)
|
)
|
||||||
|
|
||||||
authorized_http = google_auth_httplib2.AuthorizedHttp(
|
service = _make_service(creds)
|
||||||
creds, http=httplib2.Http(timeout=_TIMEOUT)
|
|
||||||
)
|
|
||||||
service = build("androidpublisher", "v3", http=authorized_http)
|
|
||||||
|
|
||||||
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
|
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
|
||||||
edit_id = edit["id"]
|
edit_id = edit["id"]
|
||||||
|
|
||||||
media = MediaFileUpload(AAB_PATH, mimetype="application/octet-stream", resumable=True)
|
# The resumable upload can fail with RedirectMissingLocation on transient
|
||||||
bundle = (
|
# network hiccups. Retry the upload (with a fresh MediaFileUpload each
|
||||||
service.edits()
|
# time) using exponential backoff before giving up.
|
||||||
.bundles()
|
version_code = None
|
||||||
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
|
last_exc = None
|
||||||
.execute(num_retries=3)
|
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||||
)
|
try:
|
||||||
version_code = bundle["versionCode"]
|
media = MediaFileUpload(
|
||||||
|
AAB_PATH, mimetype="application/octet-stream", resumable=True
|
||||||
|
)
|
||||||
|
bundle = (
|
||||||
|
service.edits()
|
||||||
|
.bundles()
|
||||||
|
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
|
||||||
|
.execute(num_retries=3)
|
||||||
|
)
|
||||||
|
version_code = bundle["versionCode"]
|
||||||
|
break
|
||||||
|
except httplib2.error.RedirectMissingLocation as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||||
|
delay = 10 * (2 ** attempt)
|
||||||
|
print(
|
||||||
|
f"Upload attempt {attempt + 1} failed (redirect error), "
|
||||||
|
f"retrying in {delay}s…"
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
print(f"Uploaded AAB, version code: {version_code}")
|
print(f"Uploaded AAB, version code: {version_code}")
|
||||||
|
|
||||||
service.edits().tracks().update(
|
service.edits().tracks().update(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
@@ -10,8 +12,16 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||||
|
|
||||||
|
Future<imap.ImapClient> _fakeImapConnect(
|
||||||
|
Account account,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async =>
|
||||||
|
throw const SocketException('fake — no real IMAP server in tests');
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('AccountSyncManager schedules sync for multiple accounts', () async {
|
test('AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||||
|
() async {
|
||||||
final accounts = _FakeAccounts('pw');
|
final accounts = _FakeAccounts('pw');
|
||||||
final mailboxes = _FakeMailboxes();
|
final mailboxes = _FakeMailboxes();
|
||||||
final emails = _FakeEmails();
|
final emails = _FakeEmails();
|
||||||
@@ -22,6 +32,7 @@ void main() {
|
|||||||
mailboxes,
|
mailboxes,
|
||||||
emails,
|
emails,
|
||||||
syncLog: logs,
|
syncLog: logs,
|
||||||
|
imapConnect: _fakeImapConnect,
|
||||||
);
|
);
|
||||||
|
|
||||||
final a1 = _account('1');
|
final a1 = _account('1');
|
||||||
@@ -38,6 +49,34 @@ void main() {
|
|||||||
|
|
||||||
manager.dispose();
|
manager.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||||
|
() async {
|
||||||
|
final accounts = _FakeAccounts('pw');
|
||||||
|
final mailboxes = _FakeMailboxes();
|
||||||
|
final emails = _FakeEmails();
|
||||||
|
final logs = _FakeLogs();
|
||||||
|
|
||||||
|
final manager = AccountSyncManager(
|
||||||
|
accounts,
|
||||||
|
mailboxes,
|
||||||
|
emails,
|
||||||
|
syncLog: logs,
|
||||||
|
);
|
||||||
|
|
||||||
|
final a1 = _jmapAccount('1');
|
||||||
|
final a2 = _jmapAccount('2');
|
||||||
|
|
||||||
|
manager.start();
|
||||||
|
accounts.push([a1, a2]);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||||
|
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||||
|
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Account _account(String id) => Account(
|
Account _account(String id) => Account(
|
||||||
@@ -52,6 +91,17 @@ Account _account(String id) => Account(
|
|||||||
smtpSsl: false,
|
smtpSsl: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Account _jmapAccount(String id) => Account(
|
||||||
|
id: id,
|
||||||
|
displayName: 'Account $id',
|
||||||
|
email: '$id@example.com',
|
||||||
|
type: AccountType.jmap,
|
||||||
|
jmapUrl: 'http://localhost:8080/.well-known/jmap',
|
||||||
|
smtpHost: 'localhost',
|
||||||
|
smtpPort: 25,
|
||||||
|
smtpSsl: false,
|
||||||
|
);
|
||||||
|
|
||||||
class _FakeAccounts implements AccountRepository {
|
class _FakeAccounts implements AccountRepository {
|
||||||
_FakeAccounts(this.password);
|
_FakeAccounts(this.password);
|
||||||
final String password;
|
final String password;
|
||||||
@@ -140,6 +190,9 @@ class _FakeEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> moveEmail(String id, String dest) async {}
|
Future<void> moveEmail(String id, String dest) async {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
|
|
||||||
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
|
import 'db_test_helper.dart';
|
||||||
|
|
||||||
|
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verifies the [AccountRepository] interface contract.
|
||||||
|
///
|
||||||
|
/// Subclass this and override [makeRepo] to run the same suite against any
|
||||||
|
/// concrete implementation.
|
||||||
|
abstract class AccountRepositoryContract {
|
||||||
|
AccountRepository makeRepo();
|
||||||
|
|
||||||
|
static const _a = Account(
|
||||||
|
id: 'c-1',
|
||||||
|
displayName: 'Contract',
|
||||||
|
email: 'c@example.com',
|
||||||
|
imapHost: 'imap.example.com',
|
||||||
|
smtpHost: 'smtp.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
void run() {
|
||||||
|
test('observeAccounts starts empty', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
expect(await repo.observeAccounts().first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addAccount makes account visible via observeAccounts', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
final list = await repo.observeAccounts().first;
|
||||||
|
expect(list, hasLength(1));
|
||||||
|
expect(list.first.id, _a.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAccount returns null for unknown id', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
expect(await repo.getAccount('no-such'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAccount returns added account', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
final a = await repo.getAccount(_a.id);
|
||||||
|
expect(a, isNotNull);
|
||||||
|
expect(a!.email, _a.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPassword returns stored password', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'secret123');
|
||||||
|
expect(await repo.getPassword(_a.id), 'secret123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAccount reflects changes in observeAccounts', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
final updated = _a.copyWith(displayName: 'Updated');
|
||||||
|
await repo.updateAccount(updated);
|
||||||
|
final list = await repo.observeAccounts().first;
|
||||||
|
expect(list.first.displayName, 'Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAccount with password updates stored password', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'old');
|
||||||
|
await repo.updateAccount(_a, password: 'new');
|
||||||
|
expect(await repo.getPassword(_a.id), 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeAccount makes account disappear from observeAccounts',
|
||||||
|
() async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
await repo.removeAccount(_a.id);
|
||||||
|
expect(await repo.observeAccounts().first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAccount returns null after removeAccount', () async {
|
||||||
|
final repo = makeRepo();
|
||||||
|
await repo.addAccount(_a, 'pw');
|
||||||
|
await repo.removeAccount(_a.id);
|
||||||
|
expect(await repo.getAccount(_a.id), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _AccountRepositoryImplContract extends AccountRepositoryContract {
|
||||||
|
@override
|
||||||
|
AccountRepository makeRepo() =>
|
||||||
|
AccountRepositoryImpl(openTestDatabase(), MapSecureStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
|
group('AccountRepositoryImpl satisfies AccountRepository contract', () {
|
||||||
|
_AccountRepositoryImplContract().run();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
||||||
@override
|
@override
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||||
|
@override
|
||||||
Future<void> moveEmail(String id, String dest) async {}
|
Future<void> moveEmail(String id, String dest) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -215,9 +215,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Stream<List<_i2.Email>> observeEmails(
|
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||||
String accountId,
|
String? accountId,
|
||||||
String mailboxPath, {
|
String? mailboxPath, {
|
||||||
int limit = 50,
|
int? limit = 50,
|
||||||
}) =>
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -233,9 +233,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||||
String accountId,
|
String? accountId,
|
||||||
String mailboxPath, {
|
String? mailboxPath, {
|
||||||
int limit = 50,
|
int? limit = 50,
|
||||||
}) =>
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -337,6 +337,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i4.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> markAllAsRead(
|
||||||
|
String? accountId,
|
||||||
|
String? mailboxPath,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#markAllAsRead,
|
||||||
|
[
|
||||||
|
accountId,
|
||||||
|
mailboxPath,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> moveEmail(
|
_i4.Future<void> moveEmail(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||||
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
|
|
||||||
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
|
import 'db_test_helper.dart';
|
||||||
|
|
||||||
|
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verifies the observable / local-state portion of the [EmailRepository]
|
||||||
|
/// interface contract.
|
||||||
|
///
|
||||||
|
/// Network-dependent methods (syncEmails, sendEmail, etc.) are intentionally
|
||||||
|
/// excluded — they are covered by the concrete impl tests.
|
||||||
|
abstract class EmailRepositoryContract {
|
||||||
|
static const _account = Account(
|
||||||
|
id: 'er-acc',
|
||||||
|
displayName: 'Contract',
|
||||||
|
email: 'er@example.com',
|
||||||
|
imapHost: 'imap.example.com',
|
||||||
|
smtpHost: 'smtp.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Return a fresh [EmailRepository] with [_account] already persisted.
|
||||||
|
Future<EmailRepository> makeRepo();
|
||||||
|
|
||||||
|
/// Insert a raw email row so tests can assert on observable state without
|
||||||
|
/// triggering a network sync.
|
||||||
|
Future<void> insertEmail(
|
||||||
|
EmailRepository repo, {
|
||||||
|
required String id,
|
||||||
|
required String mailboxPath,
|
||||||
|
bool isSeen = true,
|
||||||
|
bool isFlagged = false,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
void run() {
|
||||||
|
test('observeEmails starts empty', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
expect(
|
||||||
|
await repo.observeEmails(_account.id, 'INBOX').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeEmails emits inserted email', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
||||||
|
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
|
||||||
|
expect(emails, hasLength(1));
|
||||||
|
expect(emails.first.id, 'er-acc:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeEmails only returns emails for the given mailbox', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
||||||
|
expect(
|
||||||
|
await repo.observeEmails(_account.id, 'Sent').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeEmails orders by receivedAt descending', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
final older = DateTime(2024);
|
||||||
|
final newer = DateTime(2024, 6);
|
||||||
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
receivedAt: older,
|
||||||
|
);
|
||||||
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:2',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
receivedAt: newer,
|
||||||
|
);
|
||||||
|
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
|
||||||
|
expect(emails.first.id, 'er-acc:2');
|
||||||
|
expect(emails.last.id, 'er-acc:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getEmail returns null for unknown id', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
expect(await repo.getEmail('no-such'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getEmail returns inserted email', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertEmail(repo, id: 'er-acc:7', mailboxPath: 'INBOX');
|
||||||
|
final email = await repo.getEmail('er-acc:7');
|
||||||
|
expect(email, isNotNull);
|
||||||
|
expect(email!.accountId, _account.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setFlag seen updates isSeen', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:10',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
isSeen: false,
|
||||||
|
);
|
||||||
|
await repo.setFlag('er-acc:10', seen: true);
|
||||||
|
final email = await repo.getEmail('er-acc:10');
|
||||||
|
expect(email!.isSeen, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setFlag flagged updates isFlagged', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:11',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
|
await repo.setFlag('er-acc:11', flagged: true);
|
||||||
|
final email = await repo.getEmail('er-acc:11');
|
||||||
|
expect(email!.isFlagged, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markAllAsRead marks every unread email in the mailbox', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:20',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
isSeen: false,
|
||||||
|
);
|
||||||
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:21',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
isSeen: false,
|
||||||
|
);
|
||||||
|
await insertEmail(
|
||||||
|
repo,
|
||||||
|
id: 'er-acc:22',
|
||||||
|
mailboxPath: 'Sent',
|
||||||
|
isSeen: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await repo.markAllAsRead(_account.id, 'INBOX');
|
||||||
|
|
||||||
|
expect((await repo.getEmail('er-acc:20'))!.isSeen, isTrue);
|
||||||
|
expect((await repo.getEmail('er-acc:21'))!.isSeen, isTrue);
|
||||||
|
// Email in a different mailbox should be untouched.
|
||||||
|
expect((await repo.getEmail('er-acc:22'))!.isSeen, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeThreads starts empty', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
expect(
|
||||||
|
await repo.observeThreads(_account.id, 'INBOX').first,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _EmailRepositoryImplContract extends EmailRepositoryContract {
|
||||||
|
static const _account = EmailRepositoryContract._account;
|
||||||
|
|
||||||
|
late AppDatabase _db;
|
||||||
|
late AccountRepositoryImpl _accountRepo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<EmailRepository> makeRepo() async {
|
||||||
|
_db = openTestDatabase();
|
||||||
|
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
|
||||||
|
await _accountRepo.addAccount(_account, 'pw');
|
||||||
|
return EmailRepositoryImpl(
|
||||||
|
_db,
|
||||||
|
_accountRepo,
|
||||||
|
imapConnect: (_, __, ___) => Future<imap.ImapClient>.error(
|
||||||
|
UnsupportedError('no IMAP in unit tests'),
|
||||||
|
),
|
||||||
|
smtpConnect: (_, __, ___) => Future<imap.SmtpClient>.error(
|
||||||
|
UnsupportedError('no SMTP in unit tests'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insertEmail(
|
||||||
|
EmailRepository repo, {
|
||||||
|
required String id,
|
||||||
|
required String mailboxPath,
|
||||||
|
bool isSeen = true,
|
||||||
|
bool isFlagged = false,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
}) async {
|
||||||
|
await _db.into(_db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
accountId: _account.id,
|
||||||
|
mailboxPath: mailboxPath,
|
||||||
|
uid: int.parse(id.split(':').last),
|
||||||
|
receivedAt: receivedAt ?? DateTime.now(),
|
||||||
|
isSeen: Value(isSeen),
|
||||||
|
isFlagged: Value(isFlagged),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
|
group('EmailRepositoryImpl satisfies EmailRepository contract', () {
|
||||||
|
_EmailRepositoryImplContract().run();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||||
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||||
|
|
||||||
|
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||||
|
import 'db_test_helper.dart';
|
||||||
|
|
||||||
|
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Verifies the [MailboxRepository] interface contract.
|
||||||
|
///
|
||||||
|
/// Tests cover only the locally-observable part of the interface
|
||||||
|
/// (observe / find) since sync methods require live IMAP/JMAP servers.
|
||||||
|
abstract class MailboxRepositoryContract {
|
||||||
|
static const _account = Account(
|
||||||
|
id: 'm-acc',
|
||||||
|
displayName: 'Contract',
|
||||||
|
email: 'm@example.com',
|
||||||
|
imapHost: 'imap.example.com',
|
||||||
|
smtpHost: 'smtp.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Return a fresh [MailboxRepository] with [_account] already persisted.
|
||||||
|
Future<MailboxRepository> makeRepo();
|
||||||
|
|
||||||
|
/// Insert a mailbox row into the backing store so tests can verify
|
||||||
|
/// observeMailboxes without triggering a network sync.
|
||||||
|
Future<void> insertMailbox(
|
||||||
|
MailboxRepository repo, {
|
||||||
|
required String id,
|
||||||
|
required String path,
|
||||||
|
String? role,
|
||||||
|
int unread = 0,
|
||||||
|
int total = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
void run() {
|
||||||
|
test('observeMailboxes starts empty', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeMailboxes emits inserted rows ordered by path', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'z', path: 'Z');
|
||||||
|
await insertMailbox(repo, id: 'a', path: 'A');
|
||||||
|
final boxes = await repo.observeMailboxes(_account.id).first;
|
||||||
|
expect(boxes.map((b) => b.path), ['A', 'Z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observeMailboxes only returns rows for the given account', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'mb1', path: 'INBOX');
|
||||||
|
expect(await repo.observeMailboxes('other-acc').first, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findMailboxByRole returns null when no match', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
expect(
|
||||||
|
await repo.findMailboxByRole(_account.id, 'archive'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findMailboxByRole returns the matching mailbox', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'arch', path: 'Archive', role: 'archive');
|
||||||
|
final box = await repo.findMailboxByRole(_account.id, 'archive');
|
||||||
|
expect(box, isNotNull);
|
||||||
|
expect(box!.role, 'archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearForResync removes all mailboxes for the account', () async {
|
||||||
|
final repo = await makeRepo();
|
||||||
|
await insertMailbox(repo, id: 'mb', path: 'INBOX');
|
||||||
|
await repo.clearForResync(_account.id);
|
||||||
|
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
|
||||||
|
static const _account = MailboxRepositoryContract._account;
|
||||||
|
|
||||||
|
late AppDatabase _db;
|
||||||
|
late AccountRepositoryImpl _accountRepo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MailboxRepository> makeRepo() async {
|
||||||
|
_db = openTestDatabase();
|
||||||
|
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
|
||||||
|
await _accountRepo.addAccount(_account, 'pw');
|
||||||
|
return MailboxRepositoryImpl(
|
||||||
|
_db,
|
||||||
|
_accountRepo,
|
||||||
|
imapConnect: (_, __, ___) =>
|
||||||
|
Future.error(UnsupportedError('no IMAP in unit tests')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insertMailbox(
|
||||||
|
MailboxRepository repo, {
|
||||||
|
required String id,
|
||||||
|
required String path,
|
||||||
|
String? role,
|
||||||
|
int unread = 0,
|
||||||
|
int total = 0,
|
||||||
|
}) async {
|
||||||
|
await _db.into(_db.mailboxes).insert(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
accountId: _account.id,
|
||||||
|
path: path,
|
||||||
|
name: path.split('/').last,
|
||||||
|
unreadCount: Value(unread),
|
||||||
|
totalCount: Value(total),
|
||||||
|
role: Value(role),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(configureSqliteForTests);
|
||||||
|
|
||||||
|
group('MailboxRepositoryImpl satisfies MailboxRepository contract', () {
|
||||||
|
_MailboxRepositoryImplContract().run();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 26);
|
expect(db.schemaVersion, 27);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +171,11 @@ void main() {
|
|||||||
// Verify FTS table was created and is queryable.
|
// Verify FTS table was created and is queryable.
|
||||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||||
|
|
||||||
|
// v27: search_history_entries table.
|
||||||
|
await db
|
||||||
|
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||||
|
.get();
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
});
|
});
|
||||||
@@ -301,11 +306,16 @@ void main() {
|
|||||||
);
|
);
|
||||||
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
await db.customSelect('SELECT count(*) FROM email_fts').get();
|
||||||
|
|
||||||
|
// v27: search_history_entries table.
|
||||||
|
await db
|
||||||
|
.customSelect('SELECT count(*) FROM search_history_entries')
|
||||||
|
.get();
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 26', () async {
|
test('fresh install creates all tables at schemaVersion 27', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -328,6 +338,7 @@ void main() {
|
|||||||
'threads',
|
'threads',
|
||||||
'sync_health',
|
'sync_health',
|
||||||
'undo_actions',
|
'undo_actions',
|
||||||
|
'search_history_entries',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ class _CountingEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
||||||
@override
|
@override
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||||
|
@override
|
||||||
Future<void> moveEmail(String id, String dest) async {}
|
Future<void> moveEmail(String id, String dest) async {}
|
||||||
@override
|
@override
|
||||||
Future<String?> deleteEmail(String id) async => null;
|
Future<String?> deleteEmail(String id) async => null;
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Stream<List<_i2.Email>> observeEmails(
|
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||||
String accountId,
|
String? accountId,
|
||||||
String mailboxPath, {
|
String? mailboxPath, {
|
||||||
int limit = 50,
|
int? limit = 50,
|
||||||
}) =>
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -93,9 +93,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||||
String accountId,
|
String? accountId,
|
||||||
String mailboxPath, {
|
String? mailboxPath, {
|
||||||
int limit = 50,
|
int? limit = 50,
|
||||||
}) =>
|
}) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
@@ -197,6 +197,23 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
) as _i4.Future<void>);
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> markAllAsRead(
|
||||||
|
String? accountId,
|
||||||
|
String? mailboxPath,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#markAllAsRead,
|
||||||
|
[
|
||||||
|
accountId,
|
||||||
|
mailboxPath,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> moveEmail(
|
_i4.Future<void> moveEmail(
|
||||||
String? emailId,
|
String? emailId,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_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/managesieve_probe_service.dart';
|
||||||
@@ -213,6 +214,8 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
|
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
|
||||||
|
@override
|
||||||
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
|
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
|
||||||
@@ -508,3 +511,20 @@ Email testEmail({
|
|||||||
isFlagged: isFlagged,
|
isFlagged: isFlagged,
|
||||||
hasAttachment: hasAttachment,
|
hasAttachment: hasAttachment,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ void main() {
|
|||||||
FakeMailboxRepository(),
|
FakeMailboxRepository(),
|
||||||
),
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -42,6 +45,9 @@ void main() {
|
|||||||
FakeMailboxRepository(),
|
FakeMailboxRepository(),
|
||||||
),
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -68,6 +74,9 @@ void main() {
|
|||||||
FakeMailboxRepository(),
|
FakeMailboxRepository(),
|
||||||
),
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -97,6 +106,9 @@ void main() {
|
|||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(searchResults: [email]),
|
FakeEmailRepository(searchResults: [email]),
|
||||||
),
|
),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -132,6 +144,9 @@ void main() {
|
|||||||
FakeMailboxRepository([archiveMailbox]),
|
FakeMailboxRepository([archiveMailbox]),
|
||||||
),
|
),
|
||||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -162,6 +177,9 @@ void main() {
|
|||||||
emailRepositoryProvider.overrideWithValue(
|
emailRepositoryProvider.overrideWithValue(
|
||||||
FakeEmailRepository(searchResults: [email]),
|
FakeEmailRepository(searchResults: [email]),
|
||||||
),
|
),
|
||||||
|
searchHistoryRepositoryProvider.overrideWithValue(
|
||||||
|
FakeSearchHistoryRepository(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -175,8 +193,9 @@ void main() {
|
|||||||
await tester.tap(find.byIcon(Icons.clear));
|
await tester.tap(find.byIcon(Icons.clear));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Results are gone; the recent-search chip for the prior query appears.
|
||||||
expect(find.text('Found email'), findsNothing);
|
expect(find.text('Found email'), findsNothing);
|
||||||
expect(find.text('Type 3+ characters to search'), findsOneWidget);
|
expect(find.text('found'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user