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:
co-authored by
Claude Sonnet 4.6
parent
032595d7d5
commit
02b0fec0b6
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user