Files
sharedinbox/lib/ui/screens/edit_account_screen.dart
T
Thomas GüttlerandClaude Sonnet 4.6 be56232f00 feat: linting + format automation + IMAP integration tests against Stalwart
- 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>
2026-04-20 18:08:09 +02:00

317 lines
9.4 KiB
Dart

import 'dart:async';
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 '../../di.dart';
class EditAccountScreen extends ConsumerStatefulWidget {
const EditAccountScreen({super.key, required this.accountId});
final String accountId;
@override
ConsumerState<EditAccountScreen> createState() => _EditAccountScreenState();
}
class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
final _formKey = GlobalKey<FormState>();
bool _loading = true;
bool _saving = false;
String? _errorMessage;
Account? _account;
final _displayNameCtrl = TextEditingController();
final _usernameCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _imapHostCtrl = TextEditingController();
final _imapPortCtrl = TextEditingController();
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController();
var _smtpSsl = false;
final _jmapUrlCtrl = TextEditingController();
// -- "Try connection" state ------------------------------------------------
bool _tryTesting = false;
String? _tryOk;
String? _tryErr;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final repo = ref.read(accountRepositoryProvider);
final account = await repo.getAccount(widget.accountId);
if (!mounted) return;
if (account == null) {
context.pop();
return;
}
_account = account;
_displayNameCtrl.text = account.displayName;
_usernameCtrl.text = account.username;
_imapHostCtrl.text = account.imapHost;
_imapPortCtrl.text = account.imapPort.toString();
_smtpHostCtrl.text = account.smtpHost;
_smtpPortCtrl.text = account.smtpPort.toString();
_smtpSsl = account.smtpSsl;
_jmapUrlCtrl.text = account.jmapUrl ?? '';
setState(() => _loading = false);
}
@override
void dispose() {
for (final c in [
_displayNameCtrl,
_usernameCtrl,
_passwordCtrl,
_imapHostCtrl,
_imapPortCtrl,
_smtpHostCtrl,
_smtpPortCtrl,
_jmapUrlCtrl,
]) {
c.dispose();
}
super.dispose();
}
Account _buildUpdated() {
final account = _account!;
return Account(
id: account.id,
displayName: _displayNameCtrl.text.trim(),
email: account.email,
username: _usernameCtrl.text.trim(),
type: account.type,
imapHost: _imapHostCtrl.text.trim(),
imapPort: int.tryParse(_imapPortCtrl.text) ?? account.imapPort,
smtpHost: _smtpHostCtrl.text.trim(),
smtpPort: int.tryParse(_smtpPortCtrl.text) ?? account.smtpPort,
smtpSsl: _smtpSsl,
jmapUrl:
_jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(),
);
}
Future<void> _tryConnection() async {
if (!_formKey.currentState!.validate()) return;
final password = _passwordCtrl.text.isNotEmpty
? _passwordCtrl.text
: await ref
.read(accountRepositoryProvider)
.getPassword(widget.accountId);
setState(() {
_tryTesting = true;
_tryOk = null;
_tryErr = null;
});
try {
final effective = await ref
.read(connectionTestServiceProvider)
.testConnection(_buildUpdated(), password);
if (mounted) {
setState(() {
_tryTesting = false;
_tryOk = 'Connected as $effective';
});
}
} catch (e) {
if (mounted) {
setState(() {
_tryTesting = false;
_tryErr = e.toString();
});
}
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
setState(() {
_saving = true;
_errorMessage = null;
});
try {
Account updated = _buildUpdated();
if (password != null) {
final effective = await ref
.read(connectionTestServiceProvider)
.testConnection(updated, password);
// Persist the discovered effective username when none was explicit.
if (updated.username.isEmpty) {
updated = Account(
id: updated.id,
displayName: updated.displayName,
email: updated.email,
username: effective,
type: updated.type,
imapHost: updated.imapHost,
imapPort: updated.imapPort,
smtpHost: updated.smtpHost,
smtpPort: updated.smtpPort,
smtpSsl: updated.smtpSsl,
jmapUrl: updated.jmapUrl,
);
}
}
await ref
.read(accountRepositoryProvider)
.updateAccount(updated, password: password);
if (mounted) context.pop();
} catch (e) {
if (mounted) {
setState(() {
_saving = false;
_errorMessage = 'Save failed: $e';
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit account')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _saving
? const Center(child: CircularProgressIndicator())
: _buildForm(),
);
}
Widget _buildForm() {
final account = _account!;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(account.email, style: Theme.of(context).textTheme.titleMedium),
Text(
account.type == AccountType.jmap ? 'JMAP' : 'IMAP',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
_field(_displayNameCtrl, 'Display name'),
_field(
_usernameCtrl,
'Username (leave blank to use email)',
required: false,
),
_field(
_passwordCtrl,
'New password (leave blank to keep)',
key: const Key('editPasswordField'),
obscure: true,
required: false,
),
if (account.type == AccountType.jmap) ...[
const Divider(height: 32),
_field(
_jmapUrlCtrl,
'JMAP API URL',
keyboardType: TextInputType.url,
),
],
if (account.type == AccountType.imap) ...[
const Divider(height: 32),
Text(
'IMAP (SSL/TLS)',
style: Theme.of(context).textTheme.titleSmall,
),
_field(_imapHostCtrl, 'Host'),
_field(_imapPortCtrl, 'Port', keyboardType: TextInputType.number),
const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _smtpSsl,
onChanged: (v) => setState(() => _smtpSsl = v),
),
],
if (_tryOk != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_tryOk!,
style:
TextStyle(color: Theme.of(context).colorScheme.primary),
),
),
if (_tryErr != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_tryErr!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 12),
OutlinedButton(
key: const Key('editTryConnectionButton'),
onPressed: _tryTesting ? null : _tryConnection,
child: _tryTesting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Try connection'),
),
const SizedBox(height: 8),
FilledButton(onPressed: _save, child: const Text('Save')),
],
),
),
);
}
Widget _field(
TextEditingController ctrl,
String label, {
Key? key,
bool obscure = false,
bool required = true,
TextInputType? keyboardType,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
key: key,
controller: ctrl,
obscureText: obscure,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
validator: required
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null,
),
);
}
}