feat(compose): autocomplete To/Cc from local address history

Adds RawAutocomplete<EmailAddress> to the To and Cc fields in the
compose screen. As the user types (minimum 2 chars), suggestions are
fetched from the local DB by searching from/to/cc columns of cached
emails. Selecting a suggestion appends it to any existing addresses
already in the field (comma-separated).

New repository method searchAddresses() returns deduplicated
EmailAddress objects matching the query string.

Closes #11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-14 21:30:17 +02:00
co-authored by Claude Sonnet 4.6
parent 032595d7d5
commit 02b0fec0b6
7 changed files with 172 additions and 2 deletions
@@ -57,6 +57,14 @@ abstract class EmailRepository {
/// accounts if null) whose from, to, or cc fields contain [address].
Future<List<Email>> 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<List<EmailAddress>> 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<int> flushPendingChanges(String accountId, String password);
@@ -2596,6 +2596,51 @@ class EmailRepositoryImpl implements EmailRepository {
return rows.map(_toModel).toList();
}
@override
Future<List<model.EmailAddress>> 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<bool> 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 = <String>{};
final results = <model.EmailAddress>[];
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<dynamic>;
for (final e in list) {
final map = e as Map<String, dynamic>;
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<List<model.Email>> searchEmails(
String accountId,
+89 -2
View File
@@ -39,6 +39,8 @@ class ComposeScreen extends ConsumerStatefulWidget {
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
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<ComposeScreen> {
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<ComposeScreen> {
),
),
),
_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<ComposeScreen> {
);
}
Widget _addressField(
TextEditingController ctrl,
FocusNode focusNode,
String label,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: RawAutocomplete<EmailAddress>(
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, {
@@ -236,6 +236,14 @@ class _FakeEmails implements EmailRepository {
@override
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
String? a,
String q, {
int limit = 10,
}) async =>
[];
@override
Stream<void> watchJmapPush(String accountId, String password) =>
const Stream.empty();
+7
View File
@@ -94,6 +94,13 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
String? a,
String q, {
int limit = 10,
}) async =>
[];
@override
Stream<void> watchJmapPush(String a, String p) => const Stream.empty();
@override
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
+7
View File
@@ -119,6 +119,13 @@ class _CountingEmails implements EmailRepository {
@override
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
String? a,
String q, {
int limit = 10,
}) async =>
[];
@override
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
Stream.value([]);
@override
+8
View File
@@ -273,6 +273,14 @@ class FakeEmailRepository implements EmailRepository {
) async =>
[];
@override
Future<List<EmailAddress>> searchAddresses(
String? accountId,
String query, {
int limit = 10,
}) async =>
[];
@override
Stream<void> watchJmapPush(String accountId, String password) =>
const Stream.empty();