Files
sharedinbox/lib/ui/screens/compose_screen.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 02b0fec0b6 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>
2026-05-14 21:30:17 +02:00

509 lines
16 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:mime/mime.dart';
import 'package:open_filex/open_filex.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/di.dart';
class ComposeScreen extends ConsumerStatefulWidget {
const ComposeScreen({
super.key,
this.accountId,
this.replyToEmailId,
this.prefillTo,
this.prefillCc,
this.prefillSubject,
this.prefillBody,
});
final String? accountId;
final String? replyToEmailId;
final String? prefillTo;
final String? prefillCc;
final String? prefillSubject;
final String? prefillBody;
@override
ConsumerState<ComposeScreen> createState() => _ComposeScreenState();
}
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;
List<Account> _accounts = [];
bool _sending = false;
final List<_AttachmentInfo> _attachments = [];
bool _opening = false;
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;
unawaited(_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) unawaited(_restoreDraft());
for (final c in [_to, _cc, _subject, _body]) {
c.addListener(_onTextChanged);
}
}
Future<void> _loadAccounts() async {
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (!mounted) return;
setState(() {
_accounts = accounts;
_accountId ??= accounts.isNotEmpty ? accounts.first.id : null;
});
}
Future<void> _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<void> _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();
}
_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) {
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<void> _pickAttachments() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return;
final files = result.files.where((f) => f.path != null).toList();
if (!mounted) return;
final newAttachments = <_AttachmentInfo>[];
for (final file in files) {
final path = file.path!;
final stat = await File(path).stat();
newAttachments.add(
_AttachmentInfo(
path: path,
filename: file.name,
size: stat.size,
contentType: _guessMimeType(file.name),
),
);
}
setState(() => _attachments.addAll(newAttachments));
}
void _removeAttachment(int index) {
setState(() => _attachments.removeAt(index));
}
Future<void> _openAttachment(int index) async {
if (_opening) return;
setState(() => _opening = true);
try {
final path = _attachments[index].path;
await OpenFilex.open(path);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Failed to open file: $e'),
),
);
} finally {
if (mounted) setState(() => _opening = false);
}
}
String _guessMimeType(String filename) {
return lookupMimeType(filename) ?? 'application/octet-stream';
}
Future<void> _send() async {
if (_accountId == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Select an account first'),
),
);
return;
}
setState(() => _sending = true);
try {
final account =
(await ref.read(accountRepositoryProvider).getAccount(_accountId!))!;
final draft = EmailDraft(
from: EmailAddress(name: account.displayName, email: account.email),
to: _to.text
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map((e) => EmailAddress(email: e))
.toList(),
cc: _cc.text
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map((e) => EmailAddress(email: e))
.toList(),
subject: _subject.text,
body: _body.text,
attachmentFilePaths: List.unmodifiable(
_attachments.map((a) => a.path).toList(),
),
);
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;
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Send failed: $e'),
),
);
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
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',
onPressed: _sending ? null : _pickAttachments,
),
IconButton(
icon: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
onPressed: _sending ? null : _send,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (_accounts.length > 1)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: DropdownButtonFormField<String>(
initialValue: _accountId,
decoration: const InputDecoration(
labelText: 'From',
border: OutlineInputBorder(),
),
items: _accounts
.map(
(a) => DropdownMenuItem(
value: a.id,
child: Text('${a.displayName} <${a.email}>'),
),
)
.toList(),
onChanged: (v) => setState(() => _accountId = v),
),
)
else if (_accounts.length == 1)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'From',
border: OutlineInputBorder(),
),
child: Text(
'${_accounts.first.displayName} <${_accounts.first.email}>',
),
),
),
_addressField(_to, _toFocus, 'To'),
_addressField(_cc, _ccFocus, 'Cc'),
_field(_subject, 'Subject'),
const SizedBox(height: 8),
TextFormField(
controller: _body,
maxLines: null,
minLines: 10,
decoration: const InputDecoration(
labelText: 'Body',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
),
if (_attachments.isNotEmpty) ...[
const SizedBox(height: 8),
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'Attachments',
style: Theme.of(context).textTheme.titleSmall,
),
),
for (var i = 0; i < _attachments.length; i++)
ListTile(
dense: true,
leading: const Icon(Icons.attach_file),
title: Text(_attachments[i].filename),
subtitle: Text(
'${_attachments[i].contentType}${fmtSize(_attachments[i].size)}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: 'Open',
onPressed: () => _openAttachment(i),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Remove',
onPressed: () => _removeAttachment(i),
),
],
),
),
],
],
),
);
}
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, {
TextInputType? keyboardType,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
controller: ctrl,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class _AttachmentInfo {
final String path;
final String filename;
final int size;
final String contentType;
_AttachmentInfo({
required this.path,
required this.filename,
required this.size,
required this.contentType,
});
}
// Helper to silence the unawaited_futures lint on fire-and-forget futures.
void unawaited(Future<void> _) {}