feat: add-account wizard, edit account, inbox tap, connection status

- Add account wizard: email-first flow with JMAP/IMAP auto-detection
  via well-known URLs; falls back to manual type selection
- Fix JMAP connection probe: GET session URL with Basic auth instead
  of the API endpoint, so 401 reliably signals bad credentials
- Account list tile: tap → open INBOX directly; popup menu for
  all mailboxes / edit / delete (with confirmation dialog)
- Show account type (JMAP/IMAP) and async connection status per tile:
  spinner while checking, green check on success, red error on failure
- Add EditAccountScreen: edit name, password, server settings; runs
  connection test only when password is changed
- Fix GTK window initialisation order so app starts with correct size
- Fix 42 lint issues (avoid_redundant_argument_values,
  unnecessary_non_null_assertion, unawaited_futures)
- 147 tests, 87% coverage, task check green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-18 15:13:47 +02:00
co-authored by Claude Sonnet 4.6
parent e2a87fc2b0
commit 442c3c4087
26 changed files with 1452 additions and 283 deletions
+16 -7
View File
@@ -1,8 +1,12 @@
/// Represents a configured IMAP/SMTP account stored in the local DB.
enum AccountType { imap, jmap }
class Account {
final String id;
final String displayName;
final String email;
final AccountType type;
// Used when type == AccountType.imap
final String imapHost;
final int imapPort;
final bool imapSsl;
@@ -10,15 +14,20 @@ class Account {
final int smtpPort;
final bool smtpSsl;
// Used when type == AccountType.jmap
final String? jmapUrl;
const Account({
required this.id,
required this.displayName,
required this.email,
required this.imapHost,
required this.imapPort,
required this.imapSsl,
required this.smtpHost,
required this.smtpPort,
required this.smtpSsl,
this.type = AccountType.imap,
this.imapHost = '',
this.imapPort = 993,
this.imapSsl = true,
this.smtpHost = '',
this.smtpPort = 587,
this.smtpSsl = false,
this.jmapUrl,
});
}
+26
View File
@@ -0,0 +1,26 @@
sealed class DiscoveryResult {}
final class JmapDiscovery extends DiscoveryResult {
final String apiUrl;
JmapDiscovery({required this.apiUrl});
}
final class ImapSmtpDiscovery extends DiscoveryResult {
final String imapHost;
final int imapPort;
final bool imapSsl;
final String smtpHost;
final int smtpPort;
final bool smtpSsl;
ImapSmtpDiscovery({
required this.imapHost,
required this.imapPort,
required this.imapSsl,
required this.smtpHost,
required this.smtpPort,
required this.smtpSsl,
});
}
final class UnknownDiscovery extends DiscoveryResult {}
@@ -4,6 +4,8 @@ abstract class AccountRepository {
Stream<List<Account>> observeAccounts();
Future<Account?> getAccount(String id);
Future<void> addAccount(Account account, String password);
/// Updates account fields. Pass [password] to also update the stored password.
Future<void> updateAccount(Account account, {String? password});
Future<void> removeAccount(String id);
Future<String> getPassword(String accountId);
}
@@ -0,0 +1,98 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/discovery_result.dart';
abstract class AccountDiscoveryService {
Future<DiscoveryResult> discover(String email);
}
class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
AccountDiscoveryServiceImpl(this._client);
final http.Client _client;
@override
Future<DiscoveryResult> discover(String email) async {
final atIdx = email.indexOf('@');
if (atIdx < 0) return UnknownDiscovery();
final domain = email.substring(atIdx + 1).toLowerCase();
final jmap = await _tryJmap(domain);
if (jmap != null) return jmap;
final imap = await _tryImapAutoconfig(domain);
if (imap != null) return imap;
return UnknownDiscovery();
}
Future<JmapDiscovery?> _tryJmap(String domain) async {
try {
final url = Uri.https(domain, '/.well-known/jmap');
final resp =
await _client.get(url).timeout(const Duration(seconds: 5));
if (resp.statusCode != 200) return null;
final json = jsonDecode(resp.body) as Map<String, dynamic>;
final apiUrl = json['apiUrl'] as String?;
if (apiUrl == null || apiUrl.isEmpty) return null;
return JmapDiscovery(apiUrl: apiUrl);
} catch (_) {
return null;
}
}
Future<ImapSmtpDiscovery?> _tryImapAutoconfig(String domain) async {
final urls = [
Uri.https('autoconfig.$domain', '/mail/config-v1.1.xml'),
Uri.https(domain, '/.well-known/autoconfig/mail/config-v1.1.xml'),
];
for (final url in urls) {
try {
final resp =
await _client.get(url).timeout(const Duration(seconds: 5));
if (resp.statusCode != 200) continue;
final result = _parseAutoconfig(resp.body);
if (result != null) return result;
} catch (_) {
continue;
}
}
return null;
}
ImapSmtpDiscovery? _parseAutoconfig(String xml) {
final imapBlock = RegExp(
r'<incomingServer\s+type="imap"[^>]*>([\s\S]*?)</incomingServer>',
).firstMatch(xml)?.group(1);
final smtpBlock = RegExp(
r'<outgoingServer\s+type="smtp"[^>]*>([\s\S]*?)</outgoingServer>',
).firstMatch(xml)?.group(1);
if (imapBlock == null || smtpBlock == null) return null;
final imapHost = _tag(imapBlock, 'hostname');
final imapPort = int.tryParse(_tag(imapBlock, 'port') ?? '') ?? 993;
final imapSsl = _tag(imapBlock, 'socketType')?.toUpperCase() == 'SSL';
final smtpHost = _tag(smtpBlock, 'hostname');
final smtpPort = int.tryParse(_tag(smtpBlock, 'port') ?? '') ?? 587;
final smtpSsl = _tag(smtpBlock, 'socketType')?.toUpperCase() == 'SSL';
if (imapHost == null || smtpHost == null) return null;
return ImapSmtpDiscovery(
imapHost: imapHost,
imapPort: imapPort,
imapSsl: imapSsl,
smtpHost: smtpHost,
smtpPort: smtpPort,
smtpSsl: smtpSsl,
);
}
String? _tag(String block, String tag) =>
RegExp('<$tag>([^<]+)</$tag>').firstMatch(block)?.group(1)?.trim();
}
@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/account.dart';
import '../../data/imap/imap_client_factory.dart';
abstract class ConnectionTestService {
/// Verifies credentials by opening and immediately closing a connection.
/// Throws a descriptive [Exception] on failure.
Future<void> testConnection(Account account, String password);
}
class ConnectionTestServiceImpl implements ConnectionTestService {
ConnectionTestServiceImpl(this._httpClient);
final http.Client _httpClient;
@override
Future<void> testConnection(Account account, String password) async {
switch (account.type) {
case AccountType.imap:
final client = await connectImap(account, password);
await client.logout();
case AccountType.jmap:
await _testJmap(account.jmapUrl!, account.email, password);
}
}
Future<void> _testJmap(
String apiUrl,
String email,
String password,
) async {
// Derive the JMAP session URL from the email domain. According to RFC 8620,
// a GET to /.well-known/jmap with Basic auth returns 200 on success and
// 401 on bad credentials — a reliable login check without a POST.
final atIdx = email.indexOf('@');
final domain = atIdx >= 0 ? email.substring(atIdx + 1) : email;
final sessionUri = Uri.https(domain, '/.well-known/jmap');
final credentials = base64.encode(utf8.encode('$email:$password'));
final resp = await _httpClient
.get(sessionUri, headers: {'Authorization': 'Basic $credentials'})
.timeout(const Duration(seconds: 10));
if (resp.statusCode == 401 || resp.statusCode == 403) {
throw Exception('Authentication failed: wrong email or password');
}
if (resp.statusCode != 200) {
throw Exception('Connection failed (HTTP ${resp.statusCode})');
}
}
}
+2 -1
View File
@@ -25,8 +25,9 @@ class AccountSyncManager {
_accountsSub = _accounts.observeAccounts().listen((accounts) {
final currentIds = accounts.map((a) => a.id).toSet();
// Start sync for newly added accounts.
// Start sync for newly added IMAP accounts only.
for (final account in accounts) {
if (account.type != AccountType.imap) continue;
if (!_active.containsKey(account.id)) {
final sync = _AccountSync(account, _accounts, _mailboxes, _emails);
_active[account.id] = sync;
+17 -2
View File
@@ -19,6 +19,10 @@ class Accounts extends Table {
TextColumn get smtpHost => text()();
IntColumn get smtpPort => integer()();
BoolColumn get smtpSsl => boolean()();
// Added in schema v2:
TextColumn get accountType =>
text().withDefault(const Constant('imap'))();
TextColumn get jmapUrl => text().nullable()();
@override
Set<Column> get primaryKey => {id};
@@ -54,7 +58,8 @@ class Emails extends Table {
TextColumn get preview => text().nullable()();
BoolColumn get isSeen => boolean().withDefault(const Constant(false))();
BoolColumn get isFlagged => boolean().withDefault(const Constant(false))();
BoolColumn get hasAttachment => boolean().withDefault(const Constant(false))();
BoolColumn get hasAttachment =>
boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
@@ -80,7 +85,17 @@ class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(accounts, accounts.accountType);
await m.addColumn(accounts, accounts.jmapUrl);
}
},
);
}
LazyDatabase _openConnection() {
@@ -1,3 +1,5 @@
import 'package:drift/drift.dart' show Value;
import '../../core/models/account.dart' as model;
import '../../core/repositories/account_repository.dart';
import '../../core/storage/secure_storage.dart';
@@ -37,11 +39,33 @@ class AccountRepositoryImpl implements AccountRepository {
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
smtpSsl: account.smtpSsl,
accountType: Value(account.type.name),
jmapUrl: Value(account.jmapUrl),
),
);
await _storage.write(key: _passwordKey(account.id), value: password);
}
@override
Future<void> updateAccount(model.Account account, {String? password}) async {
await (_db.update(_db.accounts)..where((t) => t.id.equals(account.id)))
.write(AccountsCompanion(
displayName: Value(account.displayName),
email: Value(account.email),
imapHost: Value(account.imapHost),
imapPort: Value(account.imapPort),
imapSsl: Value(account.imapSsl),
smtpHost: Value(account.smtpHost),
smtpPort: Value(account.smtpPort),
smtpSsl: Value(account.smtpSsl),
accountType: Value(account.type.name),
jmapUrl: Value(account.jmapUrl),
));
if (password != null) {
await _storage.write(key: _passwordKey(account.id), value: password);
}
}
@override
Future<void> removeAccount(String id) async {
await (_db.delete(_db.accounts)..where((t) => t.id.equals(id))).go();
@@ -63,11 +87,13 @@ class AccountRepositoryImpl implements AccountRepository {
id: row.id,
displayName: row.displayName,
email: row.email,
type: model.AccountType.values.byName(row.accountType),
imapHost: row.imapHost,
imapPort: row.imapPort,
imapSsl: row.imapSsl,
smtpHost: row.smtpHost,
smtpPort: row.smtpPort,
smtpSsl: row.smtpSsl,
jmapUrl: row.jmapUrl,
);
}
+28
View File
@@ -1,8 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'core/repositories/account_repository.dart';
import 'core/repositories/email_repository.dart';
import 'core/repositories/mailbox_repository.dart';
import 'core/services/account_discovery_service.dart';
import 'core/services/connection_test_service.dart';
import 'core/storage/secure_storage.dart';
import 'core/sync/account_sync_manager.dart';
import 'data/db/database.dart';
@@ -21,6 +24,12 @@ final secureStorageProvider = Provider<SecureStorage>((ref) {
return const FlutterSecureStorageImpl();
});
final httpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
final accountRepositoryProvider = Provider<AccountRepository>((ref) {
return AccountRepositoryImpl(
ref.watch(dbProvider),
@@ -51,3 +60,22 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
ref.onDispose(manager.dispose);
return manager;
});
final accountDiscoveryServiceProvider =
Provider<AccountDiscoveryService>((ref) {
return AccountDiscoveryServiceImpl(ref.watch(httpClientProvider));
});
final connectionTestServiceProvider =
Provider<ConnectionTestService>((ref) {
return ConnectionTestServiceImpl(ref.watch(httpClientProvider));
});
final accountConnectionStatusProvider =
FutureProvider.autoDispose.family<void, String>((ref, accountId) async {
final repo = ref.read(accountRepositoryProvider);
final account = await repo.getAccount(accountId);
if (account == null) throw Exception('Account not found');
final password = await repo.getPassword(accountId);
await ref.read(connectionTestServiceProvider).testConnection(account, password);
});
+7
View File
@@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart';
import 'screens/account_list_screen.dart';
import 'screens/add_account_screen.dart';
import 'screens/edit_account_screen.dart';
import 'screens/mailbox_list_screen.dart';
import 'screens/email_list_screen.dart';
import 'screens/email_detail_screen.dart';
@@ -19,6 +20,12 @@ final router = GoRouter(
path: 'add',
builder: (ctx, state) => const AddAccountScreen(),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
accountId: state.pathParameters['accountId']!,
),
),
GoRoute(
path: ':accountId/mailboxes',
builder: (ctx, state) =>
+104 -9
View File
@@ -2,6 +2,7 @@ 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 AccountListScreen extends ConsumerWidget {
@@ -44,15 +45,7 @@ class AccountListScreen extends ConsumerWidget {
}
return ListView.builder(
itemCount: accounts.length,
itemBuilder: (ctx, i) {
final a = accounts[i];
return ListTile(
leading: const Icon(Icons.account_circle),
title: Text(a.displayName),
subtitle: Text(a.email),
onTap: () => context.push('/accounts/${a.id}/mailboxes'),
);
},
itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]),
);
},
),
@@ -63,3 +56,105 @@ class AccountListScreen extends ConsumerWidget {
);
}
}
class _AccountTile extends ConsumerWidget {
const _AccountTile({required this.account});
final Account account;
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(accountConnectionStatusProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile(
leading: const Icon(Icons.account_circle),
title: Text(account.displayName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(account.email),
Text(
typeLabel,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => const [
PopupMenuItem(
value: _AccountAction.allMailboxes,
child: Text('All mailboxes'),
),
PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
PopupMenuDivider(),
PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push(
'/accounts/${account.id}/mailboxes/${Uri.encodeComponent('INBOX')}/emails',
),
);
}
Future<void> _onAction(BuildContext context, _AccountAction action) async {
switch (action) {
case _AccountAction.allMailboxes:
await context.push('/accounts/${account.id}/mailboxes');
case _AccountAction.edit:
await context.push('/accounts/${account.id}/edit');
case _AccountAction.delete:
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete account'),
content: Text(
'Remove "${account.displayName}" (${account.email})? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
if ((confirmed ?? false) && context.mounted) {
await ProviderScope.containerOf(context)
.read(accountRepositoryProvider)
.removeAccount(account.id);
}
}
}
}
enum _AccountAction { allMailboxes, edit, delete }
+306 -57
View File
@@ -3,8 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/models/account.dart';
import '../../core/models/discovery_result.dart';
import '../../di.dart';
enum _Step { email, detecting, chooseType, jmapForm, imapForm, connecting }
class AddAccountScreen extends ConsumerStatefulWidget {
const AddAccountScreen({super.key});
@@ -13,87 +16,307 @@ class AddAccountScreen extends ConsumerStatefulWidget {
}
class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
final _form = GlobalKey<FormState>();
final _displayName = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
final _imapHost = TextEditingController();
final _imapPort = TextEditingController(text: '993');
bool _imapSsl = true;
final _smtpHost = TextEditingController();
final _smtpPort = TextEditingController(text: '587');
bool _smtpSsl = false;
bool _saving = false;
var _step = _Step.email;
String? _errorMessage;
// -- controllers -----------------------------------------------------------
final _emailCtrl = TextEditingController();
final _displayNameCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _jmapApiUrlCtrl = TextEditingController();
final _imapHostCtrl = TextEditingController();
final _imapPortCtrl = TextEditingController(text: '993');
var _imapSsl = true;
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController(text: '587');
var _smtpSsl = false;
// -- form keys -------------------------------------------------------------
final _emailFormKey = GlobalKey<FormState>();
final _jmapFormKey = GlobalKey<FormState>();
final _imapFormKey = GlobalKey<FormState>();
@override
void dispose() {
for (final c in [
_displayName,
_email,
_password,
_imapHost,
_imapPort,
_smtpHost,
_smtpPort,
_emailCtrl,
_displayNameCtrl,
_passwordCtrl,
_jmapApiUrlCtrl,
_imapHostCtrl,
_imapPortCtrl,
_smtpHostCtrl,
_smtpPortCtrl,
]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (!_form.currentState!.validate()) return;
setState(() => _saving = true);
// -- actions ---------------------------------------------------------------
Future<void> _detectAccount() async {
if (!_emailFormKey.currentState!.validate()) return;
setState(() {
_step = _Step.detecting;
_errorMessage = null;
});
try {
final result = await ref
.read(accountDiscoveryServiceProvider)
.discover(_emailCtrl.text.trim());
if (!mounted) return;
switch (result) {
case JmapDiscovery(:final apiUrl):
_jmapApiUrlCtrl.text = apiUrl;
setState(() => _step = _Step.jmapForm);
case ImapSmtpDiscovery(
:final imapHost,
:final imapPort,
:final imapSsl,
:final smtpHost,
:final smtpPort,
:final smtpSsl,
):
_imapHostCtrl.text = imapHost;
_imapPortCtrl.text = imapPort.toString();
_imapSsl = imapSsl;
_smtpHostCtrl.text = smtpHost;
_smtpPortCtrl.text = smtpPort.toString();
_smtpSsl = smtpSsl;
setState(() => _step = _Step.imapForm);
case UnknownDiscovery():
setState(() => _step = _Step.chooseType);
}
} catch (_) {
if (mounted) setState(() => _step = _Step.chooseType);
}
}
Future<void> _saveJmap() async {
if (!_jmapFormKey.currentState!.validate()) return;
setState(() {
_step = _Step.connecting;
_errorMessage = null;
});
try {
final account = Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: _displayName.text.trim(),
email: _email.text.trim(),
imapHost: _imapHost.text.trim(),
imapPort: int.parse(_imapPort.text),
displayName: _displayNameCtrl.text.trim(),
email: _emailCtrl.text.trim(),
type: AccountType.jmap,
jmapUrl: _jmapApiUrlCtrl.text.trim(),
);
await ref
.read(connectionTestServiceProvider)
.testConnection(account, _passwordCtrl.text);
await ref
.read(accountRepositoryProvider)
.addAccount(account, _passwordCtrl.text);
if (mounted) context.pop();
} catch (e) {
if (mounted) {
setState(() {
_step = _Step.jmapForm;
_errorMessage = 'Connection failed: $e';
});
}
}
}
Future<void> _saveImap() async {
if (!_imapFormKey.currentState!.validate()) return;
setState(() {
_step = _Step.connecting;
_errorMessage = null;
});
try {
final account = Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: _displayNameCtrl.text.trim(),
email: _emailCtrl.text.trim(),
imapHost: _imapHostCtrl.text.trim(),
imapPort: int.parse(_imapPortCtrl.text),
imapSsl: _imapSsl,
smtpHost: _smtpHost.text.trim(),
smtpPort: int.parse(_smtpPort.text),
smtpHost: _smtpHostCtrl.text.trim(),
smtpPort: int.parse(_smtpPortCtrl.text),
smtpSsl: _smtpSsl,
);
await ref
.read(connectionTestServiceProvider)
.testConnection(account, _passwordCtrl.text);
await ref
.read(accountRepositoryProvider)
.addAccount(account, _password.text);
.addAccount(account, _passwordCtrl.text);
if (mounted) context.pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) setState(() => _saving = false);
if (mounted) {
setState(() {
_step = _Step.imapForm;
_errorMessage = 'Connection failed: $e';
});
}
}
}
// -- build -----------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add account')),
body: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
body: switch (_step) {
_Step.email => _buildEmailStep(),
_Step.detecting =>
_buildSpinner('Detecting account settings\u2026'),
_Step.chooseType => _buildChooseTypeStep(),
_Step.jmapForm => _buildJmapForm(),
_Step.imapForm => _buildImapForm(),
_Step.connecting => _buildSpinner('Connecting\u2026'),
},
);
}
// -- step widgets ----------------------------------------------------------
Widget _buildEmailStep() {
return Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _emailFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_field(_displayName, 'Display name'),
_field(_email, 'Email address', keyboardType: TextInputType.emailAddress),
_field(_password, 'Password', obscure: true),
const Divider(),
const Text('IMAP', style: TextStyle(fontWeight: FontWeight.bold)),
_field(_imapHost, 'IMAP host'),
_field(_imapPort, 'Port', keyboardType: TextInputType.number),
TextFormField(
key: const Key('emailField'),
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Email address',
border: OutlineInputBorder(),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required';
if (!v.contains('@')) return 'Enter a valid email address';
return null;
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: _detectAccount,
child: const Text('Continue'),
),
],
),
),
);
}
Widget _buildSpinner(String label) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(label),
],
),
);
}
Widget _buildChooseTypeStep() {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Could not auto-detect settings for '
'${_emailCtrl.text.trim()}.\n'
'Choose account type:',
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => setState(() {
_jmapApiUrlCtrl.clear();
_step = _Step.jmapForm;
}),
child: const Text('JMAP'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => setState(() {
_imapHostCtrl.clear();
_imapPortCtrl.text = '993';
_imapSsl = true;
_smtpHostCtrl.clear();
_smtpPortCtrl.text = '587';
_smtpSsl = false;
_step = _Step.imapForm;
}),
child: const Text('IMAP / SMTP'),
),
],
),
);
}
Widget _buildJmapForm() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _jmapFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_emailHeader('JMAP'),
if (_errorMessage != null) _errorBanner(),
_field(_displayNameCtrl, 'Display name'),
_field(_jmapApiUrlCtrl, 'JMAP API URL',
keyboardType: TextInputType.url),
_field(_passwordCtrl, 'Password', obscure: true),
const SizedBox(height: 24),
FilledButton(
onPressed: _saveJmap,
child: const Text('Save'),
),
],
),
),
);
}
Widget _buildImapForm() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _imapFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_emailHeader('IMAP / SMTP'),
if (_errorMessage != null) _errorBanner(),
_field(_displayNameCtrl, 'Display name'),
_field(_passwordCtrl, 'Password', obscure: true),
const Divider(height: 32),
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
_field(_imapHostCtrl, 'Host'),
_field(_imapPortCtrl, 'Port',
keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _imapSsl,
onChanged: (v) => setState(() => _imapSsl = v),
),
const Divider(),
const Text('SMTP', style: TextStyle(fontWeight: FontWeight.bold)),
_field(_smtpHost, 'SMTP host'),
_field(_smtpPort, '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,
@@ -101,14 +324,8 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
onPressed: _saveImap,
child: const Text('Save'),
),
],
),
@@ -116,6 +333,37 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
);
}
// -- small helpers ---------------------------------------------------------
Widget _emailHeader(String accountTypeLabel) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_emailCtrl.text.trim(),
style: Theme.of(context).textTheme.titleMedium,
),
Text(
accountTypeLabel,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
Widget _errorBanner() {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
);
}
Widget _field(
TextEditingController ctrl,
String label, {
@@ -132,7 +380,8 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
labelText: label,
border: const OutlineInputBorder(),
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Required' : null,
),
);
}
+224
View File
@@ -0,0 +1,224 @@
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 _passwordCtrl = TextEditingController();
final _imapHostCtrl = TextEditingController();
final _imapPortCtrl = TextEditingController();
var _imapSsl = true;
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController();
var _smtpSsl = false;
final _jmapUrlCtrl = TextEditingController();
@override
void initState() {
super.initState();
_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;
_imapHostCtrl.text = account.imapHost;
_imapPortCtrl.text = account.imapPort.toString();
_imapSsl = account.imapSsl;
_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,
_passwordCtrl,
_imapHostCtrl,
_imapPortCtrl,
_smtpHostCtrl,
_smtpPortCtrl,
_jmapUrlCtrl,
]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final account = _account!;
final password =
_passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
final updated = Account(
id: account.id,
displayName: _displayNameCtrl.text.trim(),
email: account.email,
type: account.type,
imapHost: _imapHostCtrl.text.trim(),
imapPort: int.tryParse(_imapPortCtrl.text) ?? account.imapPort,
imapSsl: _imapSsl,
smtpHost: _smtpHostCtrl.text.trim(),
smtpPort: int.tryParse(_smtpPortCtrl.text) ?? account.smtpPort,
smtpSsl: _smtpSsl,
jmapUrl:
_jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(),
);
setState(() {
_saving = true;
_errorMessage = null;
});
try {
if (password != null) {
await ref
.read(connectionTestServiceProvider)
.testConnection(updated, password);
}
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(_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', style: Theme.of(context).textTheme.titleSmall),
_field(_imapHostCtrl, 'Host'),
_field(_imapPortCtrl, 'Port',
keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _imapSsl,
onChanged: (v) => setState(() => _imapSsl = v),
),
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),
),
],
const SizedBox(height: 24),
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,
),
);
}
}
+1
View File
@@ -12,6 +12,7 @@ dependencies:
sdk: flutter
enough_mail: ^2.1.7
http: ^1.2.0
# Local persistence (offline-first)
drift: ^2.20.3
@@ -39,6 +39,9 @@ class _FakeAccounts implements AccountRepository {
@override
Future<void> addAccount(Account account, String pass) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
@@ -117,7 +120,6 @@ void main() {
imapSsl: false,
smtpHost: imapHost,
smtpPort: smtpPort,
smtpSsl: false,
);
final mgr = AccountSyncManager(
@@ -0,0 +1,108 @@
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:test/test.dart';
import 'package:sharedinbox/core/models/discovery_result.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
const _jmapJson = '{"apiUrl":"https://mail.example.com/jmap/api/"}';
const _autoconfigXml = '''<?xml version="1.0"?>
<clientConfig>
<emailProvider>
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.example.com</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
</outgoingServer>
</emailProvider>
</clientConfig>''';
http.Client _clientFor(Map<String, http.Response> responses) {
return MockClient((request) async {
final key = request.url.toString();
return responses[key] ??
http.Response('Not found', 404);
});
}
AccountDiscoveryService _service(Map<String, http.Response> responses) =>
AccountDiscoveryServiceImpl(_clientFor(responses));
void main() {
group('AccountDiscoveryService', () {
test('returns UnknownDiscovery for email without @', () async {
final svc = _service({});
final result = await svc.discover('notanemail');
expect(result, isA<UnknownDiscovery>());
});
test('returns JmapDiscovery when well-known/jmap responds with apiUrl',
() async {
final svc = _service({
'https://example.com/.well-known/jmap':
http.Response(_jmapJson, 200),
});
final result = await svc.discover('user@example.com');
expect(result, isA<JmapDiscovery>());
expect((result as JmapDiscovery).apiUrl,
'https://mail.example.com/jmap/api/');
});
test('returns UnknownDiscovery when JMAP response has no apiUrl', () async {
final svc = _service({
'https://example.com/.well-known/jmap': http.Response('{}', 200),
});
final result = await svc.discover('user@example.com');
expect(result, isA<UnknownDiscovery>());
});
test('returns ImapSmtpDiscovery from primary autoconfig URL', () async {
final svc = _service({
'https://autoconfig.example.com/mail/config-v1.1.xml':
http.Response(_autoconfigXml, 200),
});
final result = await svc.discover('user@example.com');
expect(result, isA<ImapSmtpDiscovery>());
final imap = result as ImapSmtpDiscovery;
expect(imap.imapHost, 'imap.example.com');
expect(imap.imapPort, 993);
expect(imap.imapSsl, isTrue);
expect(imap.smtpHost, 'smtp.example.com');
expect(imap.smtpPort, 587);
expect(imap.smtpSsl, isFalse);
});
test('returns ImapSmtpDiscovery from fallback well-known autoconfig URL',
() async {
final svc = _service({
'https://example.com/.well-known/autoconfig/mail/config-v1.1.xml':
http.Response(_autoconfigXml, 200),
});
final result = await svc.discover('user@example.com');
expect(result, isA<ImapSmtpDiscovery>());
});
test('prefers JMAP over IMAP when both respond', () async {
final svc = _service({
'https://example.com/.well-known/jmap':
http.Response(_jmapJson, 200),
'https://autoconfig.example.com/mail/config-v1.1.xml':
http.Response(_autoconfigXml, 200),
});
final result = await svc.discover('user@example.com');
expect(result, isA<JmapDiscovery>());
});
test('returns UnknownDiscovery when nothing is found', () async {
final svc = _service({});
final result = await svc.discover('user@example.com');
expect(result, isA<UnknownDiscovery>());
});
});
}
-8
View File
@@ -11,11 +11,7 @@ void main() {
displayName: 'Work',
email: 'me@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
test('stores all fields', () {
@@ -36,11 +32,7 @@ void main() {
displayName: 'Work',
email: 'me@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
expect(identical(account, same), isTrue);
});
@@ -34,11 +34,7 @@ const _account = Account(
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
AccountRepositoryImpl _makeRepo() =>
@@ -112,11 +108,7 @@ void main() {
displayName: 'Alice Updated',
email: 'alice@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
await repo.addAccount(updated, 'v2');
final accounts = await repo.observeAccounts().first;
+3 -4
View File
@@ -27,6 +27,9 @@ class FakeAccountRepository implements AccountRepository {
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
@@ -98,11 +101,7 @@ const _account = Account(
displayName: 'Test',
email: 'test@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -19,11 +19,7 @@ const _account = Account(
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
Future<imap.ImapClient> _noImapConnect(Account a, String p) =>
@@ -18,11 +18,7 @@ const _account = Account(
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
Future<imap.ImapClient> _noImapConnect(Account a, String p) =>
@@ -106,11 +102,7 @@ void main() {
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
await r.accounts.addAccount(other, 'pw2');
+60 -86
View File
@@ -1,25 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('AccountListScreen', () {
testWidgets('shows "No accounts yet." when repository is empty',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()));
await tester.pumpAndSettle();
expect(find.text('No accounts yet.'), findsOneWidget);
@@ -30,33 +19,57 @@ void main() {
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
overrides: baseOverrides(accounts: [kTestAccount]),
));
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
expect(find.text('IMAP'), findsOneWidget);
});
testWidgets('shows IMAP type label for IMAP account', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(accounts: [kTestAccount]),
));
await tester.pumpAndSettle();
expect(find.text('IMAP'), findsOneWidget);
});
testWidgets('shows check icon after successful connection test',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(accounts: [kTestAccount]),
));
// Before settling: connection test is in-flight → spinner visible.
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// After settling: connection succeeded → check icon visible.
await tester.pumpAndSettle();
expect(find.byIcon(Icons.check_circle), findsOneWidget);
});
testWidgets('shows error icon when connection test fails', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
connectionError: Exception('auth failed'),
),
));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.error_outline), findsOneWidget);
});
testWidgets('app bar shows "SharedInbox" title', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()));
await tester.pumpAndSettle();
expect(find.text('SharedInbox'), findsOneWidget);
@@ -64,17 +77,8 @@ void main() {
testWidgets('tapping settings icon navigates to /settings',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.settings));
@@ -86,37 +90,24 @@ void main() {
testWidgets(
'"Add account" button in empty state navigates to add-account screen',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()));
await tester.pumpAndSettle();
await tester.tap(find.text('Add account'));
await tester.pumpAndSettle();
expect(find.text('Display name'), findsOneWidget);
expect(find.text('Email address'), findsOneWidget);
});
testWidgets('tapping an account tile navigates to its mailboxes',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository([kTestMailbox])),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
overrides: baseOverrides(
accounts: [kTestAccount],
mailboxes: [kTestMailbox],
),
));
await tester.pumpAndSettle();
@@ -127,17 +118,8 @@ void main() {
});
testWidgets('tapping FAB navigates to add-account screen', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()));
await tester.pumpAndSettle();
await tester.tap(find.byType(FloatingActionButton));
@@ -146,23 +128,15 @@ void main() {
expect(find.text('Add account'), findsOneWidget);
});
testWidgets('AppBar does not overflow at minimum supported window size', (tester) async {
testWidgets('AppBar does not overflow at minimum supported window size',
(tester) async {
tester.view.physicalSize = const Size(400, 300);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpWidget(
buildApp(initialLocation: '/accounts', overrides: baseOverrides()));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
+205 -80
View File
@@ -1,115 +1,240 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/core/models/discovery_result.dart';
import 'helpers.dart';
// Wrap AddAccountScreen in a minimal GoRouter so context.pop() doesn't throw
// when the save succeeds in other tests.
Widget _buildScreen(List<Override> overrides) {
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (ctx, state) => const AddAccountScreen(),
),
],
);
return ProviderScope(
overrides: overrides,
child: MaterialApp.router(
routerConfig: router,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
),
);
}
List<Override> get _overrides => [
accountRepositoryProvider.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
];
/// Drags the form's ListView so the bottom of the form (Save button) is
/// visible. The drag distance is empirically large enough to clear all
/// fields in the default 800×600 test viewport.
Future<void> _scrollToBottom(WidgetTester tester) async {
await tester.drag(find.byType(ListView), const Offset(0, -2000));
await tester.pumpAndSettle();
}
void main() {
group('AddAccountScreen', () {
testWidgets('renders fields visible at the top of the form',
(tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
testWidgets('step 1: shows email field and Continue button', (tester) async {
await tester.pumpWidget(
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()));
await tester.pumpAndSettle();
// These fields are near the top and visible without scrolling.
expect(find.text('Add account'), findsOneWidget); // app-bar title
expect(find.text('Display name'), findsOneWidget);
expect(find.text('Add account'), findsOneWidget);
expect(find.text('Email address'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
expect(find.text('Continue'), findsOneWidget);
});
testWidgets('Save button is reachable by scrolling', (tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
testWidgets('step 1: empty submit shows validation error', (tester) async {
await tester.pumpWidget(
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()));
await tester.pumpAndSettle();
await _scrollToBottom(tester);
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('Save'), findsOneWidget);
expect(find.text('Required'), findsOneWidget);
});
testWidgets('shows "Required" validation errors when submitted empty',
(tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
testWidgets('step 1: invalid email shows validation error', (tester) async {
await tester.pumpWidget(
buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()));
await tester.pumpAndSettle();
// Scroll to the Save button and tap it.
await _scrollToBottom(tester);
await tester.enterText(find.byKey(const Key('emailField')), 'notanemail');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('Enter a valid email address'), findsOneWidget);
});
testWidgets('unknown discovery shows choose-type step', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(discovery: UnknownDiscovery()),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('JMAP'), findsOneWidget);
expect(find.text('IMAP / SMTP'), findsOneWidget);
});
testWidgets('JMAP discovery navigates directly to JMAP form', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(
discovery: JmapDiscovery(apiUrl: 'https://mail.example.com/jmap'),
),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('JMAP API URL'), findsOneWidget);
expect(find.text('https://mail.example.com/jmap'), findsOneWidget);
});
testWidgets('IMAP discovery navigates directly to IMAP form', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(
discovery: ImapSmtpDiscovery(
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
),
),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('IMAP / SMTP'), findsWidgets);
expect(find.text('imap.example.com'), findsOneWidget);
expect(find.text('smtp.example.com'), findsOneWidget);
});
testWidgets('choose-type: tapping JMAP shows JMAP form', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(discovery: UnknownDiscovery()),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
await tester.tap(find.text('JMAP'));
await tester.pumpAndSettle();
expect(find.text('JMAP API URL'), findsOneWidget);
});
testWidgets('choose-type: tapping IMAP/SMTP shows IMAP form', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(discovery: UnknownDiscovery()),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
await tester.tap(find.text('IMAP / SMTP'));
await tester.pumpAndSettle();
expect(find.text('IMAP'), findsWidgets);
expect(find.text('SMTP'), findsWidgets);
});
testWidgets('successful JMAP save pops back to accounts list', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(
discovery: JmapDiscovery(apiUrl: 'https://mail.example.com/jmap'),
),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextFormField, 'Display name'), 'Alice');
await tester.enterText(find.widgetWithText(TextFormField, 'Password'), 'secret');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
// Scroll back to the top to see the first validation errors.
await tester.drag(find.byType(ListView), const Offset(0, 2000));
await tester.pumpAndSettle();
expect(find.text('Required'), findsWidgets);
expect(find.text('No accounts yet.'), findsOneWidget);
});
testWidgets('IMAP SSL toggle is on by default', (tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
await tester.pumpAndSettle();
// The IMAP SSL tile is near the top and visible without scrolling.
final imapTile = tester.widget<SwitchListTile>(find.ancestor(
of: find.text('SSL/TLS').first,
matching: find.byType(SwitchListTile),
testWidgets('JMAP connection failure shows error message', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(
discovery: JmapDiscovery(apiUrl: 'https://mail.example.com/jmap'),
connectionError: Exception('auth failed'),
),
));
expect(imapTile.value, isTrue);
});
testWidgets('SMTP SSL toggle is off by default', (tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
await tester.pumpAndSettle();
// Scroll to reveal the SMTP section.
await _scrollToBottom(tester);
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextFormField, 'Display name'), 'Alice');
await tester.enterText(find.widgetWithText(TextFormField, 'Password'), 'wrong');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.textContaining('Connection failed'), findsOneWidget);
});
testWidgets('successful IMAP save pops back to accounts list', (tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(
discovery: ImapSmtpDiscovery(
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
),
),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextFormField, 'Display name'), 'Alice');
await tester.enterText(find.widgetWithText(TextFormField, 'Password'), 'secret');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('No accounts yet.'), findsOneWidget);
});
testWidgets('IMAP SSL is on by default', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(discovery: UnknownDiscovery()),
));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'user@example.com');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
await tester.tap(find.text('IMAP / SMTP'));
await tester.pumpAndSettle();
// After scrolling, there are two SwitchListTile widgets in the tree;
// the last one is the SMTP SSL toggle.
final tiles = tester
.widgetList<SwitchListTile>(find.byType(SwitchListTile))
.toList();
expect(tiles.first.value, isTrue, reason: 'IMAP SSL on by default');
expect(tiles.last.value, isFalse, reason: 'SMTP SSL off by default');
});
});
-4
View File
@@ -79,11 +79,7 @@ void main() {
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
await tester.pumpWidget(buildApp(
initialLocation: '/compose',
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
void main() {
group('EditAccountScreen', () {
testWidgets('shows account email and type label after loading',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(accounts: [kTestAccount]),
));
await tester.pumpAndSettle();
expect(find.text('alice@example.com'), findsOneWidget);
// "IMAP" appears as both the type badge and the IMAP section header.
expect(find.text('IMAP'), findsWidgets);
});
testWidgets('pre-fills display name field', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(accounts: [kTestAccount]),
));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextFormField, 'Alice'), findsOneWidget);
});
testWidgets('shows Save button', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(accounts: [kTestAccount]),
));
await tester.pumpAndSettle();
expect(find.text('Save'), findsOneWidget);
});
testWidgets('saving without password change pops back', (tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(accounts: [kTestAccount]),
));
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
// After saving we pop back to the accounts list.
expect(find.text('No accounts yet.'), findsNothing);
});
testWidgets('saving with new password runs connection test', (tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(accounts: [kTestAccount]),
));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('editPasswordField')), 'newsecret');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
// Successful save navigates back — edit screen title is gone.
expect(find.text('Edit account'), findsNothing);
});
testWidgets('connection error shows error message', (tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
connectionError: Exception('auth failed'),
),
));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('editPasswordField')), 'wrongpassword');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.textContaining('Save failed'), findsOneWidget);
});
});
}
+62 -4
View File
@@ -9,14 +9,19 @@ 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/models/discovery_result.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart';
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
@@ -47,6 +52,12 @@ class FakeAccountRepository implements AccountRepository {
Future<void> addAccount(Account account, String password) async =>
_accounts.add(account);
@override
Future<void> updateAccount(Account account, {String? password}) async {
final idx = _accounts.indexWhere((a) => a.id == account.id);
if (idx >= 0) _accounts[idx] = account;
}
@override
Future<void> removeAccount(String id) async =>
_accounts.removeWhere((a) => a.id == id);
@@ -121,6 +132,28 @@ class FakeEmailRepository implements EmailRepository {
_searchResults;
}
// ---------------------------------------------------------------------------
// Fake services
// ---------------------------------------------------------------------------
class FakeDiscoveryService implements AccountDiscoveryService {
FakeDiscoveryService(this._result);
final DiscoveryResult _result;
@override
Future<DiscoveryResult> discover(String email) async => _result;
}
class FakeConnectionTestService implements ConnectionTestService {
FakeConnectionTestService({Exception? error}) : _error = error;
final Exception? _error;
@override
Future<void> testConnection(Account account, String password) async {
if (_error != null) throw _error;
}
}
// ---------------------------------------------------------------------------
// App builder
// ---------------------------------------------------------------------------
@@ -144,6 +177,12 @@ Widget buildApp({
path: 'add',
builder: (ctx, state) => const AddAccountScreen(),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
accountId: state.pathParameters['accountId']!,
),
),
GoRoute(
path: ':accountId/mailboxes',
builder: (ctx, state) => MailboxListScreen(
@@ -202,6 +241,29 @@ Widget buildApp({
);
}
/// Convenience override list used by most widget tests.
///
/// Includes fakes for all repositories and the two network services so tests
/// never hit the real database, network, or IMAP server.
List<Override> baseOverrides({
List<Account>? accounts,
List<Mailbox>? mailboxes,
DiscoveryResult? discovery,
Exception? connectionError,
}) =>
[
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository(accounts)),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
accountDiscoveryServiceProvider.overrideWithValue(
FakeDiscoveryService(discovery ?? UnknownDiscovery()),
),
connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError),
),
];
// ---------------------------------------------------------------------------
// Common test fixtures
// ---------------------------------------------------------------------------
@@ -211,11 +273,7 @@ const kTestAccount = Account(
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
const kTestMailbox = Mailbox(