Adds an escape hatch for when local DB falls out of sync with server: - New clearForResync() on EmailRepository/MailboxRepository truncates local emails, mailboxes, pending changes and sync-state checkpoints. EmailBodies are preserved (FK disabled during delete) so viewed content is not re-downloaded after re-sync. - AccountSyncManager.forceResync() orchestrates clear + loop restart. - Edit account screen gets an "Force full sync" OutlinedButton with a confirmation dialog; shows spinner while the operation runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
430 lines
14 KiB
Dart
430 lines
14 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 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/utils/host_utils.dart';
|
|
import 'package:sharedinbox/di.dart';
|
|
import 'package:sharedinbox/ui/widgets/try_connection_button.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 = true;
|
|
final _sieveHostCtrl = TextEditingController();
|
|
final _sievePortCtrl = TextEditingController();
|
|
var _sieveSsl = true;
|
|
var _verbose = false;
|
|
final _jmapUrlCtrl = TextEditingController();
|
|
|
|
// -- "Try connection" state ------------------------------------------------
|
|
bool _tryTesting = false;
|
|
String? _tryOk;
|
|
String? _tryErr;
|
|
bool _resyncing = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_smtpHostCtrl.addListener(_rebuild);
|
|
_sieveHostCtrl.addListener(_rebuild);
|
|
_imapHostCtrl.addListener(_rebuild);
|
|
unawaited(_load());
|
|
}
|
|
|
|
void _rebuild() => setState(() {});
|
|
|
|
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;
|
|
_sieveHostCtrl.text = account.manageSieveHost;
|
|
_sievePortCtrl.text = account.manageSievePort.toString();
|
|
_sieveSsl = account.manageSieveSsl;
|
|
_verbose = account.verbose;
|
|
_jmapUrlCtrl.text = account.jmapUrl ?? '';
|
|
setState(() => _loading = false);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_smtpHostCtrl.removeListener(_rebuild);
|
|
_sieveHostCtrl.removeListener(_rebuild);
|
|
_imapHostCtrl.removeListener(_rebuild);
|
|
for (final c in [
|
|
_displayNameCtrl,
|
|
_usernameCtrl,
|
|
_passwordCtrl,
|
|
_imapHostCtrl,
|
|
_imapPortCtrl,
|
|
_smtpHostCtrl,
|
|
_smtpPortCtrl,
|
|
_sieveHostCtrl,
|
|
_sievePortCtrl,
|
|
_jmapUrlCtrl,
|
|
]) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Account _buildUpdated() {
|
|
final account = _account!;
|
|
final imapHost = _imapHostCtrl.text.trim();
|
|
final sieveHost = _sieveHostCtrl.text.trim();
|
|
final sievePort =
|
|
int.tryParse(_sievePortCtrl.text) ?? account.manageSievePort;
|
|
// Reset the cached probe result when any field that affects the probe
|
|
// changed; the post-save probe will refill it.
|
|
final sieveSettingsChanged = imapHost != account.imapHost ||
|
|
sieveHost != account.manageSieveHost ||
|
|
sievePort != account.manageSievePort ||
|
|
_sieveSsl != account.manageSieveSsl;
|
|
final smtpHost = _smtpHostCtrl.text.trim();
|
|
final effectiveSieveHost = sieveHost.isNotEmpty ? sieveHost : imapHost;
|
|
return Account(
|
|
id: account.id,
|
|
displayName: _displayNameCtrl.text.trim(),
|
|
email: account.email,
|
|
username: _usernameCtrl.text.trim(),
|
|
type: account.type,
|
|
imapHost: imapHost,
|
|
imapPort: int.tryParse(_imapPortCtrl.text) ?? account.imapPort,
|
|
imapSsl: isLocalhost(imapHost) ? account.imapSsl : true,
|
|
smtpHost: smtpHost,
|
|
smtpPort: int.tryParse(_smtpPortCtrl.text) ?? account.smtpPort,
|
|
smtpSsl: isLocalhost(smtpHost) ? _smtpSsl : true,
|
|
manageSieveHost: sieveHost,
|
|
manageSievePort: sievePort,
|
|
manageSieveSsl: isLocalhost(effectiveSieveHost) ? _sieveSsl : true,
|
|
manageSieveAvailable:
|
|
sieveSettingsChanged ? null : account.manageSieveAvailable,
|
|
jmapUrl:
|
|
_jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(),
|
|
verbose: _verbose,
|
|
);
|
|
}
|
|
|
|
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> _forceResync() async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Force full sync?'),
|
|
content: const Text(
|
|
'This clears all locally-cached emails and mailboxes for this '
|
|
'account and immediately re-downloads everything from the server. '
|
|
'Previously viewed email content will not need to be re-downloaded.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
|
child: const Text('Force sync'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
setState(() => _resyncing = true);
|
|
try {
|
|
await ref.read(syncManagerProvider).forceResync(widget.accountId);
|
|
if (mounted) context.pop();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_resyncing = false;
|
|
_errorMessage = 'Force sync failed: $e';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
manageSieveHost: updated.manageSieveHost,
|
|
manageSievePort: updated.manageSievePort,
|
|
manageSieveSsl: updated.manageSieveSsl,
|
|
manageSieveAvailable: updated.manageSieveAvailable,
|
|
jmapUrl: updated.jmapUrl,
|
|
verbose: updated.verbose,
|
|
);
|
|
}
|
|
}
|
|
await ref
|
|
.read(accountRepositoryProvider)
|
|
.updateAccount(updated, password: password);
|
|
// Re-probe when the cached availability was cleared above.
|
|
if (updated.type == AccountType.imap &&
|
|
updated.manageSieveAvailable == null) {
|
|
unawaited(ref.read(manageSieveProbeServiceProvider).probe(updated));
|
|
}
|
|
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 || _saving || _resyncing
|
|
? 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),
|
|
if (isLocalhost(_smtpHostCtrl.text.trim()))
|
|
SwitchListTile(
|
|
title: const Text('SSL/TLS'),
|
|
value: _smtpSsl,
|
|
onChanged: (v) => setState(() => _smtpSsl = v),
|
|
),
|
|
const Divider(height: 32),
|
|
ExpansionTile(
|
|
tilePadding: EdgeInsets.zero,
|
|
title: Text(
|
|
'ManageSieve (email filters)',
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
children: [
|
|
_field(
|
|
_sieveHostCtrl,
|
|
'Host (leave blank to use IMAP host)',
|
|
required: false,
|
|
),
|
|
_field(
|
|
_sievePortCtrl,
|
|
'Port',
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
if (isLocalhost(
|
|
_sieveHostCtrl.text.trim().isNotEmpty
|
|
? _sieveHostCtrl.text.trim()
|
|
: _imapHostCtrl.text.trim(),
|
|
))
|
|
SwitchListTile(
|
|
title: const Text('SSL/TLS'),
|
|
value: _sieveSsl,
|
|
onChanged: (v) => setState(() => _sieveSsl = v),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const Divider(height: 32),
|
|
SwitchListTile(
|
|
title: const Text('Verbose protocol logging'),
|
|
subtitle: const Text(
|
|
'Writes raw protocol traffic to the sync log. '
|
|
'Disable when not debugging.',
|
|
),
|
|
value: _verbose,
|
|
onChanged: (v) => setState(() => _verbose = v),
|
|
),
|
|
TryConnectionButton(
|
|
buttonKey: const Key('editTryConnectionButton'),
|
|
testing: _tryTesting,
|
|
okMessage: _tryOk,
|
|
errorMessage: _tryErr,
|
|
onPressed: _tryConnection,
|
|
),
|
|
const SizedBox(height: 8),
|
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
|
const SizedBox(height: 8),
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.sync_problem),
|
|
label: const Text('Force full sync'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
onPressed: _forceResync,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|