feat: draft auto-save in compose screen

- Add Drafts table (schema v4 migration) with autoincrement id,
  accountId, replyToEmailId, to/cc/subject/body text, updatedAt
- DraftRepository interface + DraftRepositoryImpl (Drift)
- draftRepositoryProvider wired in di.dart
- ComposeScreen debounces saves (2 s after last keystroke), shows
  transient "Saved" indicator, restores the latest matching draft on
  open when no prefill fields are provided, deletes draft on send
- 6 new unit tests for DraftRepositoryImpl
- New widget test verifying draft restore behaviour
- FakeDraftRepository added to widget test helpers
- draft_repository.dart added to coverage no-code exclusion list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-18 19:06:02 +02:00
co-authored by Claude Sonnet 4.6
parent 2f1924be9c
commit e1e95e97ee
11 changed files with 477 additions and 7 deletions
+49
View File
@@ -10,9 +10,11 @@ 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/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/services/account_discovery_service.dart';
@@ -66,6 +68,52 @@ class FakeAccountRepository implements AccountRepository {
Future<String> getPassword(String accountId) async => 'test-password';
}
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);
}
class FakeMailboxRepository implements MailboxRepository {
final List<Mailbox> _mailboxes;
@@ -262,6 +310,7 @@ List<Override> baseOverrides({
.overrideWithValue(FakeAccountRepository(accounts)),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
accountDiscoveryServiceProvider.overrideWithValue(
FakeDiscoveryService(discovery ?? UnknownDiscovery()),
),