diff --git a/LATER.md b/LATER.md index 48f0ea3..ce88812 100644 --- a/LATER.md +++ b/LATER.md @@ -4,6 +4,7 @@ Draft auto-save +--- Flutter best practices? diff --git a/lib/core/models/draft.dart b/lib/core/models/draft.dart new file mode 100644 index 0000000..4570574 --- /dev/null +++ b/lib/core/models/draft.dart @@ -0,0 +1,21 @@ +class SavedDraft { + final int id; + final String? accountId; + final String? replyToEmailId; + final String toText; + final String ccText; + final String subjectText; + final String bodyText; + final DateTime updatedAt; + + const SavedDraft({ + required this.id, + this.accountId, + this.replyToEmailId, + required this.toText, + required this.ccText, + required this.subjectText, + required this.bodyText, + required this.updatedAt, + }); +} diff --git a/lib/core/repositories/draft_repository.dart b/lib/core/repositories/draft_repository.dart new file mode 100644 index 0000000..64e3c07 --- /dev/null +++ b/lib/core/repositories/draft_repository.dart @@ -0,0 +1,24 @@ +import '../models/draft.dart'; + +abstract class DraftRepository { + /// Inserts or updates a draft. Pass [id] to update; omit to create new. + Future saveDraft({ + int? id, + String? accountId, + String? replyToEmailId, + required String toText, + required String ccText, + required String subjectText, + required String bodyText, + }); + + /// Returns the most recent draft for the given reply context (null = new + /// message), or null if none exists. + Future findDraft({String? replyToEmailId}); + + /// Returns the draft with [id], or null. + Future getDraft(int id); + + /// Permanently removes the draft with [id]. + Future deleteDraft(int id); +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 23ed34b..27a891d 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -80,14 +80,27 @@ class EmailBodies extends Table { Set 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); + } }, ); } diff --git a/lib/data/repositories/draft_repository_impl.dart b/lib/data/repositories/draft_repository_impl.dart new file mode 100644 index 0000000..737785f --- /dev/null +++ b/lib/data/repositories/draft_repository_impl.dart @@ -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 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 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 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 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, + ); +} diff --git a/lib/di.dart b/lib/di.dart index a2e56c8..64c23e3 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'core/repositories/account_repository.dart'; +import 'core/repositories/draft_repository.dart'; import 'core/repositories/email_repository.dart'; import 'core/repositories/mailbox_repository.dart'; import 'core/services/account_discovery_service.dart'; @@ -10,6 +11,7 @@ import 'core/storage/secure_storage.dart'; import 'core/sync/account_sync_manager.dart'; import 'data/db/database.dart'; import 'data/repositories/account_repository_impl.dart'; +import 'data/repositories/draft_repository_impl.dart'; import 'data/repositories/email_repository_impl.dart'; import 'data/repositories/mailbox_repository_impl.dart'; import 'data/storage/flutter_secure_storage_impl.dart'; @@ -44,6 +46,10 @@ final mailboxRepositoryProvider = Provider((ref) { ); }); +final draftRepositoryProvider = Provider((ref) { + return DraftRepositoryImpl(ref.watch(dbProvider)); +}); + final emailRepositoryProvider = Provider((ref) { return EmailRepositoryImpl( ref.watch(dbProvider), diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index b35b2fa..f8421bf 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -7,6 +8,7 @@ import 'package:go_router/go_router.dart'; import '../../core/models/account.dart'; import '../../core/models/email.dart'; +import '../../core/repositories/draft_repository.dart'; import '../../di.dart'; class ComposeScreen extends ConsumerStatefulWidget { @@ -41,15 +43,35 @@ class _ComposeScreenState extends ConsumerState { bool _sending = false; final List _attachmentPaths = []; + int? _draftId; + bool _draftDirty = false; + Timer? _saveTimer; + bool _draftSaved = false; // drives the "Saved" badge + // Captured in initState so it remains accessible in dispose() after ref is gone. + late final DraftRepository _draftRepo; + + static const _saveDelay = Duration(seconds: 2); + @override void initState() { super.initState(); + _draftRepo = ref.read(draftRepositoryProvider); if (widget.prefillTo != null) _to.text = widget.prefillTo!; if (widget.prefillCc != null) _cc.text = widget.prefillCc!; if (widget.prefillSubject != null) _subject.text = widget.prefillSubject!; if (widget.prefillBody != null) _body.text = widget.prefillBody!; _accountId = widget.accountId; _loadAccounts(); + // Only restore if no prefill fields were provided (avoids overwriting a + // fresh reply with an old draft from a previous reply to the same email). + final hasPrefill = widget.prefillTo != null || + widget.prefillSubject != null || + widget.prefillBody != null; + if (!hasPrefill) _restoreDraft(); + + for (final c in [_to, _cc, _subject, _body]) { + c.addListener(_onTextChanged); + } } Future _loadAccounts() async { @@ -58,26 +80,84 @@ class _ComposeScreenState extends ConsumerState { if (!mounted) return; setState(() { _accounts = accounts; - // Auto-select the first account when none was pre-selected. _accountId ??= accounts.isNotEmpty ? accounts.first.id : null; }); } + Future _restoreDraft() async { + final draft = await ref + .read(draftRepositoryProvider) + .findDraft(replyToEmailId: widget.replyToEmailId); + if (draft == null || !mounted) return; + setState(() { + _draftId = draft.id; + _to.text = draft.toText; + _cc.text = draft.ccText; + _subject.text = draft.subjectText; + _body.text = draft.bodyText; + if (draft.accountId != null) _accountId = draft.accountId; + }); + } + + void _onTextChanged() { + _draftDirty = true; + _saveTimer?.cancel(); + _saveTimer = Timer(_saveDelay, _autoSave); + } + + Future _autoSave() async { + if (!_draftDirty || !mounted) return; + _draftDirty = false; + final saved = await _draftRepo.saveDraft( + id: _draftId, + accountId: _accountId, + replyToEmailId: widget.replyToEmailId, + toText: _to.text, + ccText: _cc.text, + subjectText: _subject.text, + bodyText: _body.text, + ); + if (!mounted) return; + setState(() { + _draftId = saved.id; + _draftSaved = true; + }); + // Hide the indicator after 2 seconds. + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _draftSaved = false); + }); + } + @override void dispose() { + _saveTimer?.cancel(); for (final c in [_to, _cc, _subject, _body]) { + c.removeListener(_onTextChanged); c.dispose(); } + // Flush any pending save synchronously — we can't await in dispose, but + // scheduling a microtask still runs before the isolate exits. + if (_draftDirty) { + unawaited( + _draftRepo.saveDraft( + id: _draftId, + accountId: _accountId, + replyToEmailId: widget.replyToEmailId, + toText: _to.text, + ccText: _cc.text, + subjectText: _subject.text, + bodyText: _body.text, + ), + ); + } super.dispose(); } Future _pickAttachments() async { final result = await FilePicker.platform.pickFiles(allowMultiple: true); if (result == null) return; - final paths = result.files - .map((f) => f.path) - .whereType() - .toList(); + final paths = + result.files.map((f) => f.path).whereType().toList(); if (!mounted) return; setState(() => _attachmentPaths.addAll(paths)); } @@ -116,6 +196,10 @@ class _ComposeScreenState extends ConsumerState { attachmentFilePaths: List.unmodifiable(_attachmentPaths), ); await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft); + // Delete the draft only after a successful send. + if (_draftId != null) { + await _draftRepo.deleteDraft(_draftId!); + } if (mounted) context.pop(); } catch (e) { if (!mounted) return; @@ -132,6 +216,16 @@ class _ComposeScreenState extends ConsumerState { appBar: AppBar( title: const Text('Compose'), actions: [ + if (_draftSaved) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: Text( + 'Saved', + style: TextStyle(fontSize: 12), + ), + ), + ), IconButton( icon: const Icon(Icons.attach_file), tooltip: 'Add attachment', @@ -243,3 +337,6 @@ class _ComposeScreenState extends ConsumerState { ); } } + +// Helper to silence the unawaited_futures lint on fire-and-forget futures. +void unawaited(Future _) {} diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index a9f0e43..e9bfee7 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -12,6 +12,7 @@ const _minCoveragePercent = 85; // Pure-abstract interfaces: no executable code, Dart VM never instruments them. const _noCode = { 'lib/core/repositories/account_repository.dart', + 'lib/core/repositories/draft_repository.dart', 'lib/core/repositories/email_repository.dart', 'lib/core/repositories/mailbox_repository.dart', 'lib/core/storage/secure_storage.dart', diff --git a/test/unit/draft_repository_impl_test.dart b/test/unit/draft_repository_impl_test.dart new file mode 100644 index 0000000..534a83a --- /dev/null +++ b/test/unit/draft_repository_impl_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sharedinbox/data/repositories/draft_repository_impl.dart'; + +import 'db_test_helper.dart'; + +void main() { + setUpAll(configureSqliteForTests); + + group('DraftRepositoryImpl', () { + test('saveDraft creates a new row and returns it with a non-zero id', + () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + final draft = await repo.saveDraft( + toText: 'bob@example.com', + ccText: '', + subjectText: 'Hello', + bodyText: 'Hi', + ); + expect(draft.id, isNonZero); + expect(draft.toText, 'bob@example.com'); + expect(draft.subjectText, 'Hello'); + }); + + test('saveDraft with id updates existing row', () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + final created = await repo.saveDraft( + toText: 'a@example.com', + ccText: '', + subjectText: 'First', + bodyText: '', + ); + final updated = await repo.saveDraft( + id: created.id, + toText: 'b@example.com', + ccText: '', + subjectText: 'Updated', + bodyText: 'body', + ); + expect(updated.id, created.id); + expect(updated.subjectText, 'Updated'); + + final fetched = await repo.getDraft(created.id); + expect(fetched?.subjectText, 'Updated'); + }); + + test('getDraft returns null for unknown id', () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + expect(await repo.getDraft(99999), isNull); + }); + + test('findDraft returns null when no draft exists', () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + expect(await repo.findDraft(), isNull); + }); + + test('findDraft returns most recent draft for matching replyToEmailId', + () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + await repo.saveDraft( + replyToEmailId: 'email-1', + toText: 'a@example.com', + ccText: '', + subjectText: 'Older', + bodyText: '', + ); + final newer = await repo.saveDraft( + replyToEmailId: 'email-1', + toText: 'a@example.com', + ccText: '', + subjectText: 'Newer', + bodyText: 'body', + ); + final found = await repo.findDraft(replyToEmailId: 'email-1'); + expect(found?.id, newer.id); + expect(found?.subjectText, 'Newer'); + }); + + test('findDraft with null replyToEmailId finds new-message drafts', () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + // This draft is a reply and should NOT be returned. + await repo.saveDraft( + replyToEmailId: 'email-1', + toText: 'x@example.com', + ccText: '', + subjectText: 'Reply draft', + bodyText: '', + ); + final newMsg = await repo.saveDraft( + toText: 'y@example.com', + ccText: '', + subjectText: 'New draft', + bodyText: '', + ); + final found = await repo.findDraft(); + expect(found?.id, newMsg.id); + }); + + test('deleteDraft removes the row', () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + final draft = await repo.saveDraft( + toText: 'a@example.com', + ccText: '', + subjectText: 'To delete', + bodyText: '', + ); + await repo.deleteDraft(draft.id); + expect(await repo.getDraft(draft.id), isNull); + }); + }); +} diff --git a/test/widget/compose_screen_test.dart b/test/widget/compose_screen_test.dart index 41dc769..b18a2ff 100644 --- a/test/widget/compose_screen_test.dart +++ b/test/widget/compose_screen_test.dart @@ -21,6 +21,8 @@ void main() { .overrideWithValue(FakeMailboxRepository()), emailRepositoryProvider .overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider + .overrideWithValue(FakeDraftRepository()), ], )); await tester.pumpAndSettle(); @@ -45,6 +47,8 @@ void main() { .overrideWithValue(FakeMailboxRepository()), emailRepositoryProvider .overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider + .overrideWithValue(FakeDraftRepository()), ], )); await tester.pumpAndSettle(); @@ -65,6 +69,8 @@ void main() { .overrideWithValue(FakeMailboxRepository()), emailRepositoryProvider .overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider + .overrideWithValue(FakeDraftRepository()), ], )); await tester.pumpAndSettle(); @@ -90,12 +96,43 @@ void main() { .overrideWithValue(FakeMailboxRepository()), emailRepositoryProvider .overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider + .overrideWithValue(FakeDraftRepository()), ], )); await tester.pumpAndSettle(); expect(find.byType(DropdownButtonFormField), findsOneWidget); }); + + testWidgets('restores saved draft when no prefill is provided', + (tester) async { + final fakeDrafts = FakeDraftRepository(); + await fakeDrafts.saveDraft( + toText: 'carol@example.com', + ccText: '', + subjectText: 'Restored subject', + bodyText: 'Draft body', + ); + await tester.pumpWidget(_buildDirect( + screen: const ComposeScreen(), + overrides: [ + accountRepositoryProvider + .overrideWithValue(FakeAccountRepository([kTestAccount])), + mailboxRepositoryProvider + .overrideWithValue(FakeMailboxRepository()), + emailRepositoryProvider + .overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider.overrideWithValue(fakeDrafts), + ], + )); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextFormField, 'carol@example.com'), + findsOneWidget); + expect(find.widgetWithText(TextFormField, 'Restored subject'), + findsOneWidget); + }); }); } diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 3fa47a9..ad1f47d 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -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 getPassword(String accountId) async => 'test-password'; } +class FakeDraftRepository implements DraftRepository { + int _nextId = 1; + final Map _drafts = {}; + + @override + Future 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 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 getDraft(int id) async => _drafts[id]; + + @override + Future deleteDraft(int id) async => _drafts.remove(id); +} + class FakeMailboxRepository implements MailboxRepository { final List _mailboxes; @@ -262,6 +310,7 @@ List baseOverrides({ .overrideWithValue(FakeAccountRepository(accounts)), mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), accountDiscoveryServiceProvider.overrideWithValue( FakeDiscoveryService(discovery ?? UnknownDiscovery()), ),