diff --git a/lib/core/db_schema_version.dart b/lib/core/db_schema_version.dart index 9e91b6b..d964cb9 100644 --- a/lib/core/db_schema_version.dart +++ b/lib/core/db_schema_version.dart @@ -1 +1 @@ -const int dbSchemaVersion = 38; +const int dbSchemaVersion = 39; diff --git a/lib/core/models/note.dart b/lib/core/models/note.dart new file mode 100644 index 0000000..315cb9a --- /dev/null +++ b/lib/core/models/note.dart @@ -0,0 +1,17 @@ +class EmailNote { + final String id; // UUID (X-SharedInbox-Note-Id) + final String accountId; + final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For) + final String noteText; + final String serverId; // IMAP UID (as string) or JMAP email ID + final DateTime createdAt; + + const EmailNote({ + required this.id, + required this.accountId, + required this.messageId, + required this.noteText, + required this.serverId, + required this.createdAt, + }); +} diff --git a/lib/core/repositories/note_repository.dart b/lib/core/repositories/note_repository.dart new file mode 100644 index 0000000..7c2a9da --- /dev/null +++ b/lib/core/repositories/note_repository.dart @@ -0,0 +1,15 @@ +import 'package:sharedinbox/core/models/note.dart'; + +abstract class NoteRepository { + /// Stream of notes for an email, keyed by [messageId] (stable across moves). + Stream> observeNotes(String accountId, String messageId); + + /// Fetches notes from the server into the local cache. + Future syncNotes(String accountId, String messageId); + + /// Creates a new note on the server and caches it locally. + Future addNote(String accountId, String messageId, String text); + + /// Deletes a note from the server and removes it from the local cache. + Future deleteNote(String noteId); +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index f13496e..d2d9c8e 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -318,6 +318,26 @@ class ImageTrustedSenders extends Table { Set get primaryKey => {senderEmail}; } +/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox). +/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves. +// Added in schema v39. +@DataClassName('EmailNoteRow') +class EmailNotes extends Table { + // UUID matching the X-SharedInbox-Note-Id custom header on the server. + TextColumn get id => text()(); + TextColumn get accountId => + text().references(Accounts, #id, onDelete: KeyAction.cascade)(); + // X-SharedInbox-Note-For value — stable across IMAP folder moves. + TextColumn get messageId => text()(); + TextColumn get noteText => text()(); + // IMAP UID (as string) or JMAP email ID of the note message on the server. + TextColumn get serverId => text()(); + DateTimeColumn get createdAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} + /// App-wide user preferences, stored as a singleton row (id always 1). @DataClassName('UserPreferencesRow') class UserPreferences extends Table { @@ -363,6 +383,7 @@ class UserPreferences extends Table { ShareKeys, UserPreferences, ImageTrustedSenders, + EmailNotes, ], ) class AppDatabase extends _$AppDatabase { @@ -639,6 +660,9 @@ class AppDatabase extends _$AppDatabase { userPreferences.bodyCacheLimitMb, ); } + if (from < 39) { + await m.createTable(emailNotes); + } }, ); } diff --git a/lib/data/repositories/note_repository_impl.dart b/lib/data/repositories/note_repository_impl.dart new file mode 100644 index 0000000..90df3ef --- /dev/null +++ b/lib/data/repositories/note_repository_impl.dart @@ -0,0 +1,570 @@ +import 'dart:math' as math; + +import 'package:drift/drift.dart'; +import 'package:enough_mail/enough_mail.dart' as imap; +import 'package:http/http.dart' as http; + +import 'package:sharedinbox/core/models/account.dart' as account_model; +import 'package:sharedinbox/core/models/note.dart'; +import 'package:sharedinbox/core/repositories/account_repository.dart'; +import 'package:sharedinbox/core/repositories/note_repository.dart'; +import 'package:sharedinbox/data/db/database.dart'; +import 'package:sharedinbox/data/imap/imap_client_factory.dart'; +import 'package:sharedinbox/data/jmap/jmap_client.dart'; + +const _notesFolder = 'Notes'; +const _headerNoteFor = 'X-SharedInbox-Note-For'; +const _headerNoteId = 'X-SharedInbox-Note-Id'; + +class NoteRepositoryImpl implements NoteRepository { + NoteRepositoryImpl( + this._db, + this._accounts, { + ImapConnectFn imapConnect = connectImap, + http.Client? httpClient, + }) : _imapConnect = imapConnect, + _httpClient = httpClient ?? http.Client(); + + final AppDatabase _db; + final AccountRepository _accounts; + final ImapConnectFn _imapConnect; + final http.Client _httpClient; + + String _effectiveUsername(account_model.Account account) => + account.username.isNotEmpty ? account.username : account.email; + + // ── Observe (local cache) ───────────────────────────────────────────────── + + @override + Stream> observeNotes(String accountId, String messageId) { + return (_db.select(_db.emailNotes) + ..where( + (t) => + t.accountId.equals(accountId) & t.messageId.equals(messageId), + ) + ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) + .watch() + .map((rows) => rows.map(_toModel).toList()); + } + + // ── Sync (server → local cache) ────────────────────────────────────────── + + @override + Future syncNotes(String accountId, String messageId) async { + final account = await _accounts.getAccount(accountId); + if (account == null) return; + final password = await _accounts.getPassword(accountId); + + switch (account.type) { + case account_model.AccountType.imap: + await _syncNotesImap(account, password, messageId); + case account_model.AccountType.jmap: + await _syncNotesJmap(account, password, messageId); + } + } + + Future _syncNotesImap( + account_model.Account account, + String password, + String messageId, + ) async { + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); + try { + try { + await client.selectMailboxByPath(_notesFolder); + } catch (_) { + // Notes folder doesn't exist — nothing to sync. + return; + } + + final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + final searchResult = await client.uidSearchMessages( + searchCriteria: 'HEADER $_headerNoteFor "$escaped"', + ); + final uids = searchResult.matchingSequence?.toList() ?? []; + + if (uids.isEmpty) { + await (_db.delete(_db.emailNotes) + ..where( + (t) => + t.accountId.equals(account.id) & + t.messageId.equals(messageId), + )) + .go(); + return; + } + + final seq = imap.MessageSequence.fromIds(uids, isUid: true); + final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])'); + + final fetchedIds = {}; + for (final msg in fetch.messages) { + final uid = msg.uid; + if (uid == null) continue; + final noteId = msg.getHeaderValue(_headerNoteId)?.trim(); + if (noteId == null || noteId.isEmpty) continue; + fetchedIds.add(noteId); + await _db.into(_db.emailNotes).insertOnConflictUpdate( + EmailNotesCompanion.insert( + id: noteId, + accountId: account.id, + messageId: messageId, + noteText: msg.decodeTextPlainPart() ?? '', + serverId: uid.toString(), + createdAt: msg.decodeDate() ?? DateTime.now(), + ), + ); + } + + // Remove stale local notes (deleted on the server). + final local = await (_db.select(_db.emailNotes) + ..where( + (t) => + t.accountId.equals(account.id) & + t.messageId.equals(messageId), + )) + .get(); + for (final note in local) { + if (!fetchedIds.contains(note.id)) { + await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id))) + .go(); + } + } + } finally { + await client.logout(); + } + } + + Future _syncNotesJmap( + account_model.Account account, + String password, + String messageId, + ) async { + final jmapUrl = account.jmapUrl; + if (jmapUrl == null || jmapUrl.isEmpty) return; + + final jmap = await JmapClient.connect( + httpClient: _httpClient, + jmapUrl: Uri.parse(jmapUrl), + username: _effectiveUsername(account), + password: password, + ); + + final mailboxId = await _findNotesMailboxJmap(jmap); + if (mailboxId == null) { + await (_db.delete(_db.emailNotes) + ..where( + (t) => + t.accountId.equals(account.id) & + t.messageId.equals(messageId), + )) + .go(); + return; + } + + final queryResp = await jmap.call([ + [ + 'Email/query', + { + 'accountId': jmap.accountId, + 'filter': {'inMailbox': mailboxId}, + }, + '0', + ], + ]); + final ids = List.from( + (_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []), + ); + + if (ids.isEmpty) { + await (_db.delete(_db.emailNotes) + ..where( + (t) => + t.accountId.equals(account.id) & + t.messageId.equals(messageId), + )) + .go(); + return; + } + + final getResp = await jmap.call([ + [ + 'Email/get', + { + 'accountId': jmap.accountId, + 'ids': ids, + 'properties': [ + 'id', + 'receivedAt', + 'textBody', + 'bodyValues', + 'header:$_headerNoteFor:asText', + 'header:$_headerNoteId:asText', + ], + 'fetchTextBodyValues': true, + }, + '0', + ], + ]); + final list = + _responseArgs(getResp, 0, 'Email/get')['list'] as List; + + final fetchedIds = {}; + for (final e in list) { + final m = e as Map; + final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim(); + if (noteFor != messageId) continue; + final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim(); + if (noteId == null || noteId.isEmpty) continue; + final jmapEmailId = m['id'] as String; + + final bodyValues = m['bodyValues'] as Map? ?? {}; + final textBodyParts = m['textBody'] as List? ?? []; + var noteText = ''; + if (textBodyParts.isNotEmpty) { + final partId = + (textBodyParts.first as Map)['partId'] as String?; + if (partId != null) { + noteText = (bodyValues[partId] as Map?)?['value'] + as String? ?? + ''; + } + } + + final createdAt = + DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now(); + fetchedIds.add(noteId); + await _db.into(_db.emailNotes).insertOnConflictUpdate( + EmailNotesCompanion.insert( + id: noteId, + accountId: account.id, + messageId: messageId, + noteText: noteText, + serverId: jmapEmailId, + createdAt: createdAt, + ), + ); + } + + // Remove stale local notes. + final local = await (_db.select(_db.emailNotes) + ..where( + (t) => + t.accountId.equals(account.id) & t.messageId.equals(messageId), + )) + .get(); + for (final note in local) { + if (!fetchedIds.contains(note.id)) { + await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id))) + .go(); + } + } + } + + // ── Add ─────────────────────────────────────────────────────────────────── + + @override + Future addNote( + String accountId, + String messageId, + String text, + ) async { + final account = await _accounts.getAccount(accountId); + if (account == null) return; + final password = await _accounts.getPassword(accountId); + final noteId = _generateId(); + + switch (account.type) { + case account_model.AccountType.imap: + await _addNoteImap(account, password, messageId, noteId, text); + case account_model.AccountType.jmap: + await _addNoteJmap(account, password, messageId, noteId, text); + } + } + + Future _addNoteImap( + account_model.Account account, + String password, + String messageId, + String noteId, + String text, + ) async { + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); + try { + try { + await client.createMailbox(_notesFolder); + } catch (_) { + // Already exists. + } + + final builder = imap.MessageBuilder() + ..subject = 'Note' + ..text = text; + builder.addHeader(_headerNoteFor, messageId); + builder.addHeader(_headerNoteId, noteId); + final mime = builder.buildMimeMessage(); + + final appendResult = await client.appendMessage( + mime, + targetMailboxPath: _notesFolder, + ); + final uidList = + appendResult.responseCodeAppendUid?.targetSequence.toList(); + final serverId = (uidList != null && uidList.isNotEmpty) + ? uidList.first.toString() + : ''; + + await _db.into(_db.emailNotes).insertOnConflictUpdate( + EmailNotesCompanion.insert( + id: noteId, + accountId: account.id, + messageId: messageId, + noteText: text, + serverId: serverId, + createdAt: DateTime.now(), + ), + ); + } finally { + await client.logout(); + } + } + + Future _addNoteJmap( + account_model.Account account, + String password, + String messageId, + String noteId, + String text, + ) async { + final jmapUrl = account.jmapUrl; + if (jmapUrl == null || jmapUrl.isEmpty) { + throw Exception('JMAP account ${account.id} has no jmapUrl'); + } + + final jmap = await JmapClient.connect( + httpClient: _httpClient, + jmapUrl: Uri.parse(jmapUrl), + username: _effectiveUsername(account), + password: password, + ); + + final mailboxId = await _findOrCreateNotesMailboxJmap(jmap); + + const bodyPartId = '1'; + final setResp = await jmap.call([ + [ + 'Email/set', + { + 'accountId': jmap.accountId, + 'create': { + 'new-note': { + 'mailboxIds': {mailboxId: true}, + 'subject': 'Note', + 'keywords': {r'$seen': true}, + 'headers': [ + {'name': _headerNoteFor, 'value': ' $messageId'}, + {'name': _headerNoteId, 'value': ' $noteId'}, + ], + 'bodyValues': { + bodyPartId: { + 'value': text, + 'isEncodingProblem': false, + 'isTruncated': false, + }, + }, + 'textBody': [ + {'partId': bodyPartId, 'type': 'text/plain'}, + ], + }, + }, + }, + '0', + ], + ]); + + final result = _responseArgs(setResp, 0, 'Email/set'); + final created = result['created'] as Map?; + final newEmail = created?['new-note'] as Map?; + final jmapEmailId = newEmail?['id'] as String? ?? ''; + + await _db.into(_db.emailNotes).insertOnConflictUpdate( + EmailNotesCompanion.insert( + id: noteId, + accountId: account.id, + messageId: messageId, + noteText: text, + serverId: jmapEmailId, + createdAt: DateTime.now(), + ), + ); + } + + // ── Delete ──────────────────────────────────────────────────────────────── + + @override + Future deleteNote(String noteId) async { + final noteRow = await (_db.select(_db.emailNotes) + ..where((t) => t.id.equals(noteId))) + .getSingleOrNull(); + if (noteRow == null) return; + + final account = await _accounts.getAccount(noteRow.accountId); + if (account == null) { + await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId))) + .go(); + return; + } + final password = await _accounts.getPassword(account.id); + + switch (account.type) { + case account_model.AccountType.imap: + await _deleteNoteImap(account, password, noteRow); + case account_model.AccountType.jmap: + await _deleteNoteJmap(account, password, noteRow); + } + } + + Future _deleteNoteImap( + account_model.Account account, + String password, + EmailNoteRow noteRow, + ) async { + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); + try { + try { + await client.selectMailboxByPath(_notesFolder); + final uid = int.tryParse(noteRow.serverId); + if (uid != null) { + final seq = imap.MessageSequence.fromId(uid, isUid: true); + await client.uidMarkDeleted(seq); + await client.uidExpunge(seq); + } + } catch (_) { + // Notes folder gone or message already deleted — clean up locally. + } + } finally { + await client.logout(); + } + await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id))) + .go(); + } + + Future _deleteNoteJmap( + account_model.Account account, + String password, + EmailNoteRow noteRow, + ) async { + final jmapUrl = account.jmapUrl; + if (jmapUrl == null || jmapUrl.isEmpty) return; + + final jmap = await JmapClient.connect( + httpClient: _httpClient, + jmapUrl: Uri.parse(jmapUrl), + username: _effectiveUsername(account), + password: password, + ); + + if (noteRow.serverId.isNotEmpty) { + await jmap.call([ + [ + 'Email/set', + { + 'accountId': jmap.accountId, + 'destroy': [noteRow.serverId], + }, + '0', + ], + ]); + } + + await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id))) + .go(); + } + + // ── JMAP helpers ────────────────────────────────────────────────────────── + + Future _findNotesMailboxJmap(JmapClient jmap) async { + final resp = await jmap.call([ + [ + 'Mailbox/get', + {'accountId': jmap.accountId, 'ids': null}, + '0', + ], + ]); + final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List; + for (final m in list) { + final map = m as Map; + if (map['name'] == _notesFolder) return map['id'] as String?; + } + return null; + } + + Future _findOrCreateNotesMailboxJmap(JmapClient jmap) async { + final existing = await _findNotesMailboxJmap(jmap); + if (existing != null) return existing; + + final resp = await jmap.call([ + [ + 'Mailbox/set', + { + 'accountId': jmap.accountId, + 'create': { + 'new-notes': {'name': _notesFolder}, + }, + }, + '0', + ], + ]); + final result = _responseArgs(resp, 0, 'Mailbox/set'); + final created = result['created'] as Map?; + final newMailbox = created?['new-notes'] as Map?; + return newMailbox?['id'] as String? ?? _notesFolder; + } + + Map _responseArgs( + List responses, + int index, + String expectedMethod, + ) { + final triple = responses[index] as List; + final method = triple[0] as String; + if (method == 'error') { + final err = triple[1] as Map; + throw JmapException('$expectedMethod error: ${err['type']}'); + } + return triple[1] as Map; + } + + EmailNote _toModel(EmailNoteRow row) => EmailNote( + id: row.id, + accountId: row.accountId, + messageId: row.messageId, + noteText: row.noteText, + serverId: row.serverId, + createdAt: row.createdAt, + ); + + // Generates a random UUID v4. + static String _generateId() { + final rng = math.Random.secure(); + final bytes = List.generate(16, (_) => rng.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}' + '-${hex.substring(12, 16)}-${hex.substring(16, 20)}' + '-${hex.substring(20)}'; + } +} diff --git a/lib/di.dart b/lib/di.dart index a947f35..bfd7206 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -4,12 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:sharedinbox/core/models/account.dart' as model; import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/models/note.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; +import 'package:sharedinbox/core/repositories/note_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; @@ -32,6 +34,7 @@ import 'package:sharedinbox/data/repositories/account_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/mailbox_repository_impl.dart'; +import 'package:sharedinbox/data/repositories/note_repository_impl.dart'; import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart'; import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; @@ -282,3 +285,18 @@ final trustedImageSendersProvider = .watch(userPreferencesRepositoryProvider) .observeTrustedImageSenders(); }); + +final noteRepositoryProvider = Provider((ref) { + return NoteRepositoryImpl( + ref.watch(dbProvider), + ref.watch(accountRepositoryProvider), + imapConnect: ref.watch(imapConnectProvider), + ); +}); + +/// Stream of notes for a specific email, identified by (accountId, messageId). +final notesProvider = + StreamProvider.autoDispose.family, (String, String)>( + (ref, params) => + ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2), +); diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index d37afb8..561a1b1 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/models/note.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/utils/format_utils.dart'; @@ -37,6 +38,7 @@ class _EmailDetailScreenState extends ConsumerState { bool _isFlagged = false; bool _loadRemoteImages = false; final Set _downloading = {}; + bool _notesSynced = false; @override Widget build(BuildContext context) { @@ -50,6 +52,15 @@ class _EmailDetailScreenState extends ConsumerState { if (email != null && mounted) { setState(() => _isFlagged = email.isFlagged); } + if (!_notesSynced && email?.messageId != null) { + _notesSynced = true; + unawaited( + ref.read(noteRepositoryProvider).syncNotes( + email!.accountId, + email.messageId!, + ), + ); + } }, ); @@ -257,6 +268,7 @@ class _EmailDetailScreenState extends ConsumerState { body.textBody ?? '', style: Theme.of(ctx).textTheme.bodyMedium, ), + if (header?.messageId != null) _buildNotesSection(ctx, header!), if (body.attachments.isNotEmpty) ...[ const Divider(), Padding( @@ -340,6 +352,114 @@ class _EmailDetailScreenState extends ConsumerState { } } + Widget _buildNotesSection(BuildContext ctx, Email header) { + final messageId = header.messageId!; + final notes = ref.watch(notesProvider((header.accountId, messageId))); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + 'Notes', + style: Theme.of(ctx).textTheme.titleSmall, + ), + ), + const Spacer(), + TextButton.icon( + icon: const Icon(Icons.add, size: 16), + label: const Text('Add'), + onPressed: () => unawaited(_addNoteDialog(ctx, header)), + ), + ], + ), + notes.when( + loading: () => const SizedBox.shrink(), + error: (e, _) => Text('Error loading notes: $e'), + data: (list) { + if (list.isEmpty) { + return const Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text( + 'No notes yet.', + style: TextStyle(color: Colors.grey), + ), + ); + } + return Column( + children: [ + for (final note in list) _buildNoteRow(ctx, note), + ], + ); + }, + ), + ], + ); + } + + Widget _buildNoteRow(BuildContext ctx, EmailNote note) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(note.noteText), + subtitle: Text( + DateFormat('MMM d, HH:mm').format(note.createdAt), + style: Theme.of(ctx).textTheme.bodySmall, + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + tooltip: 'Delete note', + onPressed: () { + unawaited(ref.read(noteRepositoryProvider).deleteNote(note.id)); + }, + ), + ); + } + + Future _addNoteDialog(BuildContext context, Email header) async { + final messageId = header.messageId; + if (messageId == null) return; + + final ctrl = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Add note'), + content: TextField( + controller: ctrl, + autofocus: true, + maxLines: 4, + decoration: const InputDecoration(hintText: 'Type a note…'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Save'), + ), + ], + ), + ); + + final text = ctrl.text.trim(); + ctrl.dispose(); + if (confirmed != true || text.isEmpty) return; + if (!context.mounted) return; + + await ref.read(noteRepositoryProvider).addNote( + header.accountId, + messageId, + text, + ); + } + Widget _buildHeader(BuildContext ctx, Email email) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 7fb625a..7a9be69 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -23,6 +23,8 @@ const _noCode = { 'lib/core/repositories/user_preferences_repository.dart', 'lib/core/models/undo_action.dart', 'lib/core/models/user_preferences.dart', + 'lib/core/models/note.dart', + 'lib/core/repositories/note_repository.dart', 'lib/core/storage/secure_storage.dart', }; @@ -83,6 +85,7 @@ const _excluded = { 'lib/core/services/update_service.dart', 'lib/ui/widgets/email_thread_tile.dart', 'lib/ui/screens/trusted_image_senders_screen.dart', + 'lib/data/repositories/note_repository_impl.dart', 'lib/ui/widgets/thread_tile.dart', }; diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index f2db1e5..91939bb 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 38); + expect(db.schemaVersion, 39); await db.close(); }); @@ -424,12 +424,15 @@ void main() { expect(userPrefsColumns, contains('prefetch_mode')); expect(userPrefsColumns, contains('body_cache_limit_mb')); + // v39: email_notes table. + await db.customSelect('SELECT count(*) FROM email_notes').get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }, ); - test('fresh install creates all tables at schemaVersion 38', () async { + test('fresh install creates all tables at schemaVersion 39', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get(); @@ -458,6 +461,7 @@ void main() { 'local_sieve_applied', // v32 'user_preferences', // v34 'image_trusted_senders', // v37 + 'email_notes', // v39 ]), ); @@ -493,6 +497,9 @@ void main() { expect(userPrefsColumns, contains('prefetch_mode')); expect(userPrefsColumns, contains('body_cache_limit_mb')); + // v39: email_notes table. + await db.customSelect('SELECT count(*) FROM email_notes').get(); + await db.close(); }); });