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:
co-authored by
Claude Sonnet 4.6
parent
e2a87fc2b0
commit
442c3c4087
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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})');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user