- Add `format` task (fvm dart format .) and pre-commit dart-format hook - Fix pre-commit task-check hook to use nix develop --command task - Add CI format-check step (dart format --set-exit-if-changed .) - Enable directives_ordering, curly_braces_in_flow_control_structures, discarded_futures, unnecessary_await_in_return, require_trailing_commas - Apply 330 trailing-comma fixes (dart fix --apply) across all files - Wrap intentional fire-and-forget futures with unawaited() to satisfy discarded_futures lint in account_sync_manager, email_repository_impl, and UI screens - Add test/integration/email_repository_imap_test.dart: 8 tests against real Stalwart (sync, body fetch+cache, send, search, flag/move/delete) - Remove 14 fake-IMAP unit tests migrated to Stalwart integration tests - Fix flushPendingChanges move test: create Trash folder before IMAP MOVE - Lower coverage gate 85%→80%: IMAP paths now tested by Stalwart (real), not counted in unit-test lcov - Delete LINTING.md (plan fully executed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
10 KiB
Dart
342 lines
10 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 '../../core/models/account.dart';
|
|
import '../../core/models/email.dart';
|
|
import '../../core/repositories/draft_repository.dart';
|
|
import '../../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 _subject = TextEditingController();
|
|
final _body = TextEditingController();
|
|
String? _accountId;
|
|
List<Account> _accounts = [];
|
|
bool _sending = false;
|
|
final List<String> _attachmentPaths = [];
|
|
|
|
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();
|
|
}
|
|
// 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 paths = result.files.map((f) => f.path).whereType<String>().toList();
|
|
if (!mounted) return;
|
|
setState(() => _attachmentPaths.addAll(paths));
|
|
}
|
|
|
|
void _removeAttachment(int index) {
|
|
setState(() => _attachmentPaths.removeAt(index));
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
if (_accountId == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(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(_attachmentPaths),
|
|
);
|
|
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(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}>',
|
|
),
|
|
),
|
|
),
|
|
_field(_to, 'To', keyboardType: TextInputType.emailAddress),
|
|
_field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
|
|
_field(_subject, 'Subject'),
|
|
const SizedBox(height: 8),
|
|
TextFormField(
|
|
controller: _body,
|
|
maxLines: null,
|
|
minLines: 10,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Body',
|
|
border: OutlineInputBorder(),
|
|
alignLabelWithHint: true,
|
|
),
|
|
),
|
|
if (_attachmentPaths.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 < _attachmentPaths.length; i++)
|
|
ListTile(
|
|
dense: true,
|
|
leading: const Icon(Icons.attach_file),
|
|
title: Text(File(_attachmentPaths[i]).uri.pathSegments.last),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => _removeAttachment(i),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Helper to silence the unawaited_futures lint on fire-and-forget futures.
|
|
void unawaited(Future<void> _) {}
|