From 442c3c4087f8afa71e8dcb8c5afc23e2de4c3e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Sat, 18 Apr 2026 15:13:47 +0200 Subject: [PATCH] feat: add-account wizard, edit account, inbox tap, connection status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/core/models/account.dart | 23 +- lib/core/models/discovery_result.dart | 26 ++ lib/core/repositories/account_repository.dart | 2 + .../services/account_discovery_service.dart | 98 +++++ .../services/connection_test_service.dart | 52 +++ lib/core/sync/account_sync_manager.dart | 3 +- lib/data/db/database.dart | 19 +- .../repositories/account_repository_impl.dart | 26 ++ lib/di.dart | 28 ++ lib/ui/router.dart | 7 + lib/ui/screens/account_list_screen.dart | 113 +++++- lib/ui/screens/add_account_screen.dart | 363 +++++++++++++++--- lib/ui/screens/edit_account_screen.dart | 224 +++++++++++ pubspec.yaml | 1 + .../account_sync_manager_test.dart | 4 +- test/unit/account_discovery_service_test.dart | 108 ++++++ test/unit/account_model_test.dart | 8 - test/unit/account_repository_impl_test.dart | 8 - test/unit/account_sync_manager_test.dart | 7 +- test/unit/email_repository_impl_test.dart | 4 - test/unit/mailbox_repository_impl_test.dart | 8 - test/widget/account_list_screen_test.dart | 146 +++---- test/widget/add_account_screen_test.dart | 285 ++++++++++---- test/widget/compose_screen_test.dart | 4 - test/widget/edit_account_screen_test.dart | 102 +++++ test/widget/helpers.dart | 66 +++- 26 files changed, 1452 insertions(+), 283 deletions(-) create mode 100644 lib/core/models/discovery_result.dart create mode 100644 lib/core/services/account_discovery_service.dart create mode 100644 lib/core/services/connection_test_service.dart create mode 100644 lib/ui/screens/edit_account_screen.dart create mode 100644 test/unit/account_discovery_service_test.dart create mode 100644 test/widget/edit_account_screen_test.dart diff --git a/lib/core/models/account.dart b/lib/core/models/account.dart index 73762df..c5e1ee6 100644 --- a/lib/core/models/account.dart +++ b/lib/core/models/account.dart @@ -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, }); } diff --git a/lib/core/models/discovery_result.dart b/lib/core/models/discovery_result.dart new file mode 100644 index 0000000..85cc126 --- /dev/null +++ b/lib/core/models/discovery_result.dart @@ -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 {} diff --git a/lib/core/repositories/account_repository.dart b/lib/core/repositories/account_repository.dart index 4763eab..0c74ecf 100644 --- a/lib/core/repositories/account_repository.dart +++ b/lib/core/repositories/account_repository.dart @@ -4,6 +4,8 @@ abstract class AccountRepository { Stream> observeAccounts(); Future getAccount(String id); Future addAccount(Account account, String password); + /// Updates account fields. Pass [password] to also update the stored password. + Future updateAccount(Account account, {String? password}); Future removeAccount(String id); Future getPassword(String accountId); } diff --git a/lib/core/services/account_discovery_service.dart b/lib/core/services/account_discovery_service.dart new file mode 100644 index 0000000..58913ce --- /dev/null +++ b/lib/core/services/account_discovery_service.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/discovery_result.dart'; + +abstract class AccountDiscoveryService { + Future discover(String email); +} + +class AccountDiscoveryServiceImpl implements AccountDiscoveryService { + AccountDiscoveryServiceImpl(this._client); + + final http.Client _client; + + @override + Future 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 _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; + final apiUrl = json['apiUrl'] as String?; + if (apiUrl == null || apiUrl.isEmpty) return null; + return JmapDiscovery(apiUrl: apiUrl); + } catch (_) { + return null; + } + } + + Future _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']*>([\s\S]*?)', + ).firstMatch(xml)?.group(1); + + final smtpBlock = RegExp( + r']*>([\s\S]*?)', + ).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>([^<]+)').firstMatch(block)?.group(1)?.trim(); +} diff --git a/lib/core/services/connection_test_service.dart b/lib/core/services/connection_test_service.dart new file mode 100644 index 0000000..ddd3486 --- /dev/null +++ b/lib/core/services/connection_test_service.dart @@ -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 testConnection(Account account, String password); +} + +class ConnectionTestServiceImpl implements ConnectionTestService { + ConnectionTestServiceImpl(this._httpClient); + + final http.Client _httpClient; + + @override + Future 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 _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})'); + } + } +} diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 9d7b329..0d4d67d 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -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; diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 0a64644..a786faf 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -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 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 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() { diff --git a/lib/data/repositories/account_repository_impl.dart b/lib/data/repositories/account_repository_impl.dart index 9c20382..c570da3 100644 --- a/lib/data/repositories/account_repository_impl.dart +++ b/lib/data/repositories/account_repository_impl.dart @@ -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 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 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, ); } diff --git a/lib/di.dart b/lib/di.dart index e82f0ca..a2e56c8 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -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((ref) { return const FlutterSecureStorageImpl(); }); +final httpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + final accountRepositoryProvider = Provider((ref) { return AccountRepositoryImpl( ref.watch(dbProvider), @@ -51,3 +60,22 @@ final syncManagerProvider = Provider((ref) { ref.onDispose(manager.dispose); return manager; }); + +final accountDiscoveryServiceProvider = + Provider((ref) { + return AccountDiscoveryServiceImpl(ref.watch(httpClientProvider)); +}); + +final connectionTestServiceProvider = + Provider((ref) { + return ConnectionTestServiceImpl(ref.watch(httpClientProvider)); +}); + +final accountConnectionStatusProvider = + FutureProvider.autoDispose.family((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); +}); diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 482c834..97df0dd 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -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) => diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 3e53fa6..1ae6a02 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -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 _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( + 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 } diff --git a/lib/ui/screens/add_account_screen.dart b/lib/ui/screens/add_account_screen.dart index 5f744ec..9098915 100644 --- a/lib/ui/screens/add_account_screen.dart +++ b/lib/ui/screens/add_account_screen.dart @@ -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 { - final _form = GlobalKey(); - 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(); + final _jmapFormKey = GlobalKey(); + final _imapFormKey = GlobalKey(); @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 _save() async { - if (!_form.currentState!.validate()) return; - setState(() => _saving = true); + // -- actions --------------------------------------------------------------- + + Future _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 _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 _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 { ), 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 { ); } + // -- 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 { labelText: label, border: const OutlineInputBorder(), ), - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, ), ); } diff --git a/lib/ui/screens/edit_account_screen.dart b/lib/ui/screens/edit_account_screen.dart new file mode 100644 index 0000000..8a4bd21 --- /dev/null +++ b/lib/ui/screens/edit_account_screen.dart @@ -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 createState() => _EditAccountScreenState(); +} + +class _EditAccountScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 _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, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index bba3c8e..f5c404f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: sdk: flutter enough_mail: ^2.1.7 + http: ^1.2.0 # Local persistence (offline-first) drift: ^2.20.3 diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index 79c12ee..76346e1 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -39,6 +39,9 @@ class _FakeAccounts implements AccountRepository { @override Future addAccount(Account account, String pass) async {} + @override + Future updateAccount(Account account, {String? password}) async {} + @override Future removeAccount(String id) async {} @@ -117,7 +120,6 @@ void main() { imapSsl: false, smtpHost: imapHost, smtpPort: smtpPort, - smtpSsl: false, ); final mgr = AccountSyncManager( diff --git a/test/unit/account_discovery_service_test.dart b/test/unit/account_discovery_service_test.dart new file mode 100644 index 0000000..57ebfcc --- /dev/null +++ b/test/unit/account_discovery_service_test.dart @@ -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 = ''' + + + + imap.example.com + 993 + SSL + + + smtp.example.com + 587 + STARTTLS + + +'''; + +http.Client _clientFor(Map responses) { + return MockClient((request) async { + final key = request.url.toString(); + return responses[key] ?? + http.Response('Not found', 404); + }); +} + +AccountDiscoveryService _service(Map 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()); + }); + + 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()); + 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()); + }); + + 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()); + 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()); + }); + + 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()); + }); + + test('returns UnknownDiscovery when nothing is found', () async { + final svc = _service({}); + final result = await svc.discover('user@example.com'); + expect(result, isA()); + }); + }); +} diff --git a/test/unit/account_model_test.dart b/test/unit/account_model_test.dart index 0bff3ad..6ebc294 100644 --- a/test/unit/account_model_test.dart +++ b/test/unit/account_model_test.dart @@ -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); }); diff --git a/test/unit/account_repository_impl_test.dart b/test/unit/account_repository_impl_test.dart index 8f5b5b1..a5fdeff 100644 --- a/test/unit/account_repository_impl_test.dart +++ b/test/unit/account_repository_impl_test.dart @@ -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; diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 2dc9e7b..530dab1 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -27,6 +27,9 @@ class FakeAccountRepository implements AccountRepository { @override Future addAccount(Account account, String password) async {} + @override + Future updateAccount(Account account, {String? password}) async {} + @override Future 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 ───────────────────────────────────────────────────────────────────── diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 35539ac..a77299f 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -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 _noImapConnect(Account a, String p) => diff --git a/test/unit/mailbox_repository_impl_test.dart b/test/unit/mailbox_repository_impl_test.dart index 0f73e90..190b110 100644 --- a/test/unit/mailbox_repository_impl_test.dart +++ b/test/unit/mailbox_repository_impl_test.dart @@ -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 _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'); diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index 2241c70..d919442 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -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); diff --git a/test/widget/add_account_screen_test.dart b/test/widget/add_account_screen_test.dart index f7b992f..2230747 100644 --- a/test/widget/add_account_screen_test.dart +++ b/test/widget/add_account_screen_test.dart @@ -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 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 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 _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(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(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'); }); }); diff --git a/test/widget/compose_screen_test.dart b/test/widget/compose_screen_test.dart index 4dcaa13..41dc769 100644 --- a/test/widget/compose_screen_test.dart +++ b/test/widget/compose_screen_test.dart @@ -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', diff --git a/test/widget/edit_account_screen_test.dart b/test/widget/edit_account_screen_test.dart new file mode 100644 index 0000000..d72c841 --- /dev/null +++ b/test/widget/edit_account_screen_test.dart @@ -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); + }); + }); +} diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 22aa9e5..9baccbf 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -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 addAccount(Account account, String password) async => _accounts.add(account); + @override + Future updateAccount(Account account, {String? password}) async { + final idx = _accounts.indexWhere((a) => a.id == account.id); + if (idx >= 0) _accounts[idx] = account; + } + @override Future 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 discover(String email) async => _result; +} + +class FakeConnectionTestService implements ConnectionTestService { + FakeConnectionTestService({Exception? error}) : _error = error; + final Exception? _error; + + @override + Future 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 baseOverrides({ + List? accounts, + List? 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(