diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 5365fdf..0986474 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -57,6 +57,14 @@ abstract class EmailRepository { /// accounts if null) whose from, to, or cc fields contain [address]. Future> getEmailsByAddress(String? accountId, String address); + /// Returns unique email addresses from the local cache whose email or display + /// name contains [query]. Results are deduplicated and capped at [limit]. + Future> searchAddresses( + String? accountId, + String query, { + int limit = 10, + }); + /// Sends any queued local mutations for [accountId] to the server. /// Returns the number of changes successfully applied. Future flushPendingChanges(String accountId, String password); diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 7a58711..9e8965e 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2596,6 +2596,51 @@ class EmailRepositoryImpl implements EmailRepository { return rows.map(_toModel).toList(); } + @override + Future> searchAddresses( + String? accountId, + String query, { + int limit = 10, + }) async { + if (query.length < 2) return []; + final pattern = '%${query.toLowerCase()}%'; + final rows = await (_db.select(_db.emails) + ..where((t) { + Expression cond = const Constant(true); + if (accountId != null) cond = t.accountId.equals(accountId); + cond = cond & + (t.fromJson.like(pattern) | + t.toAddresses.like(pattern) | + t.ccJson.like(pattern)); + return cond; + }) + ..limit(100)) + .get(); + + final seen = {}; + final results = []; + final lowerQuery = query.toLowerCase(); + for (final row in rows) { + for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) { + final list = jsonDecode(jsonStr) as List; + for (final e in list) { + final map = e as Map; + final addr = model.EmailAddress( + name: map['name'] as String?, + email: map['email'] as String, + ); + if ((addr.email.toLowerCase().contains(lowerQuery) || + (addr.name?.toLowerCase().contains(lowerQuery) ?? false)) && + seen.add(addr.email.toLowerCase())) { + results.add(addr); + if (results.length >= limit) return results; + } + } + } + } + return results; + } + @override Future> searchEmails( String accountId, diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index df762c4..aa0d884 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -39,6 +39,8 @@ class ComposeScreen extends ConsumerStatefulWidget { class _ComposeScreenState extends ConsumerState { final _to = TextEditingController(); final _cc = TextEditingController(); + final _toFocus = FocusNode(); + final _ccFocus = FocusNode(); final _subject = TextEditingController(); final _body = TextEditingController(); String? _accountId; @@ -139,6 +141,8 @@ class _ComposeScreenState extends ConsumerState { c.removeListener(_onTextChanged); c.dispose(); } + _toFocus.dispose(); + _ccFocus.dispose(); // Flush any pending save synchronously — we can't await in dispose, but // scheduling a microtask still runs before the isolate exits. if (_draftDirty) { @@ -330,8 +334,8 @@ class _ComposeScreenState extends ConsumerState { ), ), ), - _field(_to, 'To', keyboardType: TextInputType.emailAddress), - _field(_cc, 'Cc', keyboardType: TextInputType.emailAddress), + _addressField(_to, _toFocus, 'To'), + _addressField(_cc, _ccFocus, 'Cc'), _field(_subject, 'Subject'), const SizedBox(height: 8), TextFormField( @@ -384,6 +388,89 @@ class _ComposeScreenState extends ConsumerState { ); } + Widget _addressField( + TextEditingController ctrl, + FocusNode focusNode, + String label, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: RawAutocomplete( + textEditingController: ctrl, + focusNode: focusNode, + displayStringForOption: (option) { + final text = ctrl.text; + final lastComma = text.lastIndexOf(','); + final prefix = + lastComma >= 0 ? '${text.substring(0, lastComma + 1)} ' : ''; + return '$prefix${option.email}, '; + }, + optionsBuilder: (value) async { + final text = value.text; + final lastComma = text.lastIndexOf(','); + final token = lastComma >= 0 + ? text.substring(lastComma + 1).trim() + : text.trim(); + if (token.length < 2) return const []; + return ref.read(emailRepositoryProvider).searchAddresses(null, token); + }, + fieldViewBuilder: (ctx, fieldCtrl, fieldFocusNode, onFieldSubmitted) { + return TextFormField( + controller: fieldCtrl, + focusNode: fieldFocusNode, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + onFieldSubmitted: (_) => onFieldSubmitted(), + ); + }, + optionsViewBuilder: (ctx, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (ctx, i) { + final option = options.elementAt(i); + return InkWell( + onTap: () => onSelected(option), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: option.name != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(option.name!), + Text( + option.email, + style: const TextStyle(fontSize: 12), + ), + ], + ) + : Text(option.email), + ), + ); + }, + ), + ), + ), + ); + }, + ), + ); + } + Widget _field( TextEditingController ctrl, String label, { diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index 3ca6700..d3de941 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -236,6 +236,14 @@ class _FakeEmails implements EmailRepository { @override Future> getEmailsByAddress(String? a, String address) async => []; + @override + Future> searchAddresses( + String? a, + String q, { + int limit = 10, + }) async => + []; + @override Stream watchJmapPush(String accountId, String password) => const Stream.empty(); diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 16431a4..c364d1b 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -94,6 +94,13 @@ class FakeEmailRepository implements EmailRepository { @override Future> getEmailsByAddress(String? a, String address) async => []; @override + Future> searchAddresses( + String? a, + String q, { + int limit = 10, + }) async => + []; + @override Stream watchJmapPush(String a, String p) => const Stream.empty(); @override Stream> observeFailedMutations(String a) => diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index 3393fd4..ffb947d 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -119,6 +119,13 @@ class _CountingEmails implements EmailRepository { @override Future> getEmailsByAddress(String? a, String addr) async => []; @override + Future> searchAddresses( + String? a, + String q, { + int limit = 10, + }) async => + []; + @override Stream> observeFailedMutations(String a) => Stream.value([]); @override diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 5031a4d..cd88115 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -273,6 +273,14 @@ class FakeEmailRepository implements EmailRepository { ) async => []; + @override + Future> searchAddresses( + String? accountId, + String query, { + int limit = 10, + }) async => + []; + @override Stream watchJmapPush(String accountId, String password) => const Stream.empty();