Files
sharedinbox/lib/ui/screens/compose_screen.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 2795cfe2cc fix(compose): prevent double hide() in RawAutocomplete async optionsBuilder
When focus leaves the To field while the address DB query is in flight,
the optionsBuilder Future completes AFTER RawAutocomplete has already
called hide() on the overlay. The completion triggers a second hide()
call, hitting the _zOrderIndex != null assertion in overlay.dart.

Fix: check focusNode.hasFocus after the await; return [] if focus left,
which prevents RawAutocomplete from calling show()/hide() on a closed
overlay.

Also fixes #81 partially: after undo(), push an inverse UndoAction so
the undo log retains a record and the user can re-apply the operation.

Fixes #79

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:22:20 +02:00

516 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 [];
final results = await ref
.read(emailRepositoryProvider)
.searchAddresses(null, token);
// Guard: if focus left the field while the query was running,
// return empty so RawAutocomplete doesn't call show() after hide()
// has already been called — that races into an assertion in overlay.dart.
if (!focusNode.hasFocus) return const [];
return results;
},
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> _) {}