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:
co-authored by
Claude Sonnet 4.6
parent
2f1924be9c
commit
e1e95e97ee
@@ -80,14 +80,27 @@ class EmailBodies extends Table {
|
||||
Set<Column> get primaryKey => {emailId};
|
||||
}
|
||||
|
||||
/// Auto-saved compose drafts — persisted across app restarts.
|
||||
class Drafts extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get accountId => text().nullable()();
|
||||
/// Set for replies/reply-alls; null for new messages.
|
||||
TextColumn get replyToEmailId => text().nullable()();
|
||||
TextColumn get toText => text().withDefault(const Constant(''))();
|
||||
TextColumn get ccText => text().withDefault(const Constant(''))();
|
||||
TextColumn get subjectText => text().withDefault(const Constant(''))();
|
||||
TextColumn get bodyText => text().withDefault(const Constant(''))();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
}
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@DriftDatabase(tables: [Accounts, Mailboxes, Emails, EmailBodies])
|
||||
@DriftDatabase(tables: [Accounts, Mailboxes, Emails, EmailBodies, Drafts])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -99,6 +112,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 3) {
|
||||
await m.addColumn(accounts, accounts.username);
|
||||
}
|
||||
if (from < 4) {
|
||||
await m.createTable(drafts);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../core/models/draft.dart';
|
||||
import '../../core/repositories/draft_repository.dart';
|
||||
import '../db/database.dart';
|
||||
|
||||
class DraftRepositoryImpl implements DraftRepository {
|
||||
DraftRepositoryImpl(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
@override
|
||||
Future<SavedDraft> saveDraft({
|
||||
int? id,
|
||||
String? accountId,
|
||||
String? replyToEmailId,
|
||||
required String toText,
|
||||
required String ccText,
|
||||
required String subjectText,
|
||||
required String bodyText,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
if (id != null) {
|
||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(id))).write(
|
||||
DraftsCompanion(
|
||||
accountId: Value(accountId),
|
||||
replyToEmailId: Value(replyToEmailId),
|
||||
toText: Value(toText),
|
||||
ccText: Value(ccText),
|
||||
subjectText: Value(subjectText),
|
||||
bodyText: Value(bodyText),
|
||||
updatedAt: Value(now),
|
||||
),
|
||||
);
|
||||
return SavedDraft(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
replyToEmailId: replyToEmailId,
|
||||
toText: toText,
|
||||
ccText: ccText,
|
||||
subjectText: subjectText,
|
||||
bodyText: bodyText,
|
||||
updatedAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
final newId = await _db.into(_db.drafts).insert(
|
||||
DraftsCompanion.insert(
|
||||
accountId: Value(accountId),
|
||||
replyToEmailId: Value(replyToEmailId),
|
||||
toText: Value(toText),
|
||||
ccText: Value(ccText),
|
||||
subjectText: Value(subjectText),
|
||||
bodyText: Value(bodyText),
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
return SavedDraft(
|
||||
id: newId,
|
||||
accountId: accountId,
|
||||
replyToEmailId: replyToEmailId,
|
||||
toText: toText,
|
||||
ccText: ccText,
|
||||
subjectText: subjectText,
|
||||
bodyText: bodyText,
|
||||
updatedAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SavedDraft?> findDraft({String? replyToEmailId}) async {
|
||||
final query = _db.select(_db.drafts);
|
||||
if (replyToEmailId == null) {
|
||||
query.where((t) => t.replyToEmailId.isNull());
|
||||
} else {
|
||||
query.where((t) => t.replyToEmailId.equals(replyToEmailId));
|
||||
}
|
||||
query.orderBy([(t) => OrderingTerm.desc(t.id)]);
|
||||
query.limit(1);
|
||||
final row = await query.getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SavedDraft?> getDraft(int id) async {
|
||||
final row = await (_db.select(_db.drafts)
|
||||
..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteDraft(int id) async {
|
||||
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
SavedDraft _toModel(Draft row) => SavedDraft(
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
replyToEmailId: row.replyToEmailId,
|
||||
toText: row.toText,
|
||||
ccText: row.ccText,
|
||||
subjectText: row.subjectText,
|
||||
bodyText: row.bodyText,
|
||||
updatedAt: row.updatedAt,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user