diff --git a/integration_test/app_e2e_test.dart b/integration_test/app_e2e_test.dart index 92f360d..c978931 100644 --- a/integration_test/app_e2e_test.dart +++ b/integration_test/app_e2e_test.dart @@ -317,7 +317,7 @@ void main() { // ── Check Sent folder ────────────────────────────────────────────────── // Use the drawer to switch folders (no back button on Linux desktop). - await tester.tap(find.byTooltip('Open navigation menu')); + await tester.tap(find.byTooltip('Open folders')); await tester.pumpAndSettle(); await tester.tap(find.text('Sent')); await tester.pumpAndSettle(); @@ -331,7 +331,7 @@ void main() { expect(find.text(subject), findsOneWidget); // ── Check Inbox ──────────────────────────────────────────────────────── - await tester.tap(find.byTooltip('Open navigation menu')); + await tester.tap(find.byTooltip('Open folders')); await tester.pumpAndSettle(); await tester.tap(find.text('INBOX')); await tester.pumpAndSettle(); diff --git a/lib/core/db_schema_version.dart b/lib/core/db_schema_version.dart index 3f145fe..85e2c74 100644 --- a/lib/core/db_schema_version.dart +++ b/lib/core/db_schema_version.dart @@ -1 +1 @@ -const int dbSchemaVersion = 33; +const int dbSchemaVersion = 34; diff --git a/lib/core/models/user_preferences.dart b/lib/core/models/user_preferences.dart new file mode 100644 index 0000000..9a806d5 --- /dev/null +++ b/lib/core/models/user_preferences.dart @@ -0,0 +1,6 @@ +enum MenuPosition { bottom, top } + +class UserPreferences { + const UserPreferences({this.menuPosition = MenuPosition.bottom}); + final MenuPosition menuPosition; +} diff --git a/lib/core/repositories/user_preferences_repository.dart b/lib/core/repositories/user_preferences_repository.dart new file mode 100644 index 0000000..c2f5333 --- /dev/null +++ b/lib/core/repositories/user_preferences_repository.dart @@ -0,0 +1,6 @@ +import 'package:sharedinbox/core/models/user_preferences.dart'; + +abstract class UserPreferencesRepository { + Stream observePreferences(); + Future updateMenuPosition(MenuPosition position); +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 8e2ad59..9619849 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -307,6 +307,17 @@ class LocalSieveApplied extends Table { Set get primaryKey => {accountId, messageId}; } +/// App-wide user preferences, stored as a singleton row (id always 1). +@DataClassName('UserPreferencesRow') +class UserPreferences extends Table { + IntColumn get id => integer()(); + // 'bottom' (default) | 'top' + TextColumn get menuPosition => text().withDefault(const Constant('bottom'))(); + + @override + Set get primaryKey => {id}; +} + // ── Database ────────────────────────────────────────────────────────────────── @DriftDatabase( @@ -327,6 +338,7 @@ class LocalSieveApplied extends Table { LocalSieveScripts, LocalSieveApplied, ShareKeys, + UserPreferences, ], ) class AppDatabase extends _$AppDatabase { @@ -578,6 +590,9 @@ class AppDatabase extends _$AppDatabase { await m.addColumn(syncLogs, syncLogs.errorStackTrace); await m.addColumn(syncLogs, syncLogs.isPermanent); } + if (from < 34) { + await m.createTable(userPreferences); + } }, ); } diff --git a/lib/data/repositories/user_preferences_repository_impl.dart b/lib/data/repositories/user_preferences_repository_impl.dart new file mode 100644 index 0000000..71535df --- /dev/null +++ b/lib/data/repositories/user_preferences_repository_impl.dart @@ -0,0 +1,38 @@ +import 'package:drift/drift.dart'; +import 'package:sharedinbox/core/models/user_preferences.dart' as pref; +import 'package:sharedinbox/core/repositories/user_preferences_repository.dart'; +import 'package:sharedinbox/data/db/database.dart'; + +class UserPreferencesRepositoryImpl implements UserPreferencesRepository { + UserPreferencesRepositoryImpl(this._db); + + final AppDatabase _db; + static const _rowId = 1; + + @override + Stream observePreferences() { + return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId))) + .watchSingleOrNull() + .map(_rowToModel); + } + + @override + Future updateMenuPosition(pref.MenuPosition position) async { + await _db.into(_db.userPreferences).insertOnConflictUpdate( + UserPreferencesCompanion( + id: const Value(_rowId), + menuPosition: Value(position.name), + ), + ); + } + + static pref.UserPreferences _rowToModel(UserPreferencesRow? row) { + if (row == null) return const pref.UserPreferences(); + return pref.UserPreferences( + menuPosition: pref.MenuPosition.values.firstWhere( + (e) => e.name == row.menuPosition, + orElse: () => pref.MenuPosition.bottom, + ), + ); + } +} diff --git a/lib/di.dart b/lib/di.dart index 4795cb3..f239062 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:sharedinbox/core/models/account.dart' as model; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; +import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; @@ -13,6 +14,7 @@ import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart'; +import 'package:sharedinbox/core/repositories/user_preferences_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; @@ -21,7 +23,8 @@ import 'package:sharedinbox/core/services/undo_service.dart'; import 'package:sharedinbox/core/storage/secure_storage.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart'; import 'package:sharedinbox/core/sync/reliability_runner.dart'; -import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody; +import 'package:sharedinbox/data/db/database.dart' + hide Email, EmailBody, UserPreferences; import 'package:sharedinbox/data/db/local_sieve_repository.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/jmap/sieve_repository.dart'; @@ -33,6 +36,7 @@ import 'package:sharedinbox/data/repositories/search_history_repository_impl.dar import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; import 'package:sharedinbox/data/repositories/undo_repository_impl.dart'; +import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart'; import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; /// Swappable IMAP connection factory — override in tests to use plaintext. @@ -227,3 +231,13 @@ final accountConnectionStatusProvider = .read(connectionTestServiceProvider) .testConnection(account, password); }); + +final userPreferencesRepositoryProvider = + Provider((ref) { + return UserPreferencesRepositoryImpl(ref.watch(dbProvider)); +}); + +final userPreferencesProvider = + StreamProvider.autoDispose((ref) { + return ref.watch(userPreferencesRepositoryProvider).observePreferences(); +}); diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 9cf5fcc..dcc1c66 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -20,6 +20,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart'; import 'package:sharedinbox/ui/screens/sync_log_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/undo_log_screen.dart'; +import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart'; final router = GoRouter( @@ -56,6 +57,10 @@ final router = GoRouter( path: 'about', builder: (ctx, state) => const AboutScreen(), ), + GoRoute( + path: 'preferences', + builder: (ctx, state) => const UserPreferencesScreen(), + ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index d5e88a5..f013f29 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -66,6 +67,14 @@ class AccountListScreen extends ConsumerWidget { unawaited(context.push('/accounts/about')); }, ), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Preferences'), + onTap: () { + Navigator.pop(context); // Close drawer + unawaited(context.push('/accounts/preferences')); + }, + ), ], ), ), @@ -124,7 +133,6 @@ class _AccountTile extends ConsumerWidget { if (h == null) return const Text('Sync health: Not verified yet'); final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; return Row( - mainAxisSize: MainAxisSize.min, children: [ const Text('Sync health: '), Icon( @@ -133,7 +141,13 @@ class _AccountTile extends ConsumerWidget { color: h.isHealthy ? Colors.green : Colors.orange, ), const SizedBox(width: 4), - Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'), + Flexible( + child: Text( + h.isHealthy + ? 'Healthy' + : _formatDiscrepancies(h.discrepancySummary), + ), + ), Text(' ($date)', style: const TextStyle(fontSize: 10)), ], ); @@ -293,6 +307,30 @@ class _AccountTile extends ConsumerWidget { } } +String _formatDiscrepancies(String? summary) { + if (summary == null) return 'Discrepancies found'; + try { + final decoded = jsonDecode(summary) as Map; + var missingLocally = 0; + var missingOnServer = 0; + var flagMismatches = 0; + for (final v in decoded.values) { + final m = v as Map; + missingLocally += (m['missingLocally'] as int? ?? 0); + missingOnServer += (m['missingOnServer'] as int? ?? 0); + flagMismatches += (m['flagMismatches'] as int? ?? 0); + } + final parts = []; + if (missingLocally > 0) parts.add('missing locally: $missingLocally'); + if (missingOnServer > 0) parts.add('missing on server: $missingOnServer'); + if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches'); + if (parts.isEmpty) return 'Discrepancies found'; + return 'Discrepancies found (${parts.join(', ')})'; + } catch (_) { + return 'Discrepancies found'; + } +} + class _OnboardingView extends StatelessWidget { const _OnboardingView(); diff --git a/lib/ui/screens/email_action_helpers.dart b/lib/ui/screens/email_action_helpers.dart new file mode 100644 index 0000000..91288fa --- /dev/null +++ b/lib/ui/screens/email_action_helpers.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import 'package:sharedinbox/core/models/mailbox.dart'; +import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; + +enum _MissingFolderChoice { chooseExisting, createNew } + +/// Resolves a mailbox by role, prompting the user to choose or create one when +/// the role is not found. Returns the target [Mailbox], or null if cancelled. +Future resolveMailboxByRole( + BuildContext context, + MailboxRepository mailboxRepo, + String accountId, + String currentMailboxPath, + String role, { + required String dialogTitle, + required String createFolderName, +}) async { + Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role); + if (!context.mounted) return null; + if (mailbox != null) return mailbox; + + final choice = await showDialog<_MissingFolderChoice>( + context: context, + builder: (ctx) => AlertDialog( + title: Text(dialogTitle), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(ctx, _MissingFolderChoice.chooseExisting), + child: const Text('Choose existing folder'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew), + child: Text('Create "$createFolderName"'), + ), + ], + ), + ); + if (!context.mounted || choice == null) return null; + + switch (choice) { + case _MissingFolderChoice.chooseExisting: + final mailboxes = await mailboxRepo.observeMailboxes(accountId).first; + if (!context.mounted) return null; + final chosen = await showModalBottomSheet( + context: context, + builder: (ctx) => ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text( + 'Move to…', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + for (final m + in mailboxes.where((m) => m.path != currentMailboxPath)) + ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(m.name), + onTap: () => Navigator.pop(ctx, m.path), + ), + ], + ), + ); + if (chosen == null || !context.mounted) return null; + mailbox = mailboxes.firstWhere((m) => m.path == chosen); + case _MissingFolderChoice.createNew: + mailbox = await mailboxRepo.createMailboxWithRole( + accountId, + createFolderName, + role, + ); + if (!context.mounted) return null; + } + + return mailbox; +} diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 1184835..7a8f4a8 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/utils/format_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -85,42 +86,12 @@ class _EmailDetailScreenState extends ConsumerState { }, ), IconButton( - icon: const Icon(Icons.mark_email_unread_outlined), - tooltip: 'Mark as unread', - onPressed: () async { - await repo.setFlag(widget.emailId, seen: false); - if (context.mounted) context.pop(); - }, - ), - IconButton( - icon: Icon( - _isFlagged ? Icons.star : Icons.star_border, - color: _isFlagged ? Colors.amber : null, - ), - tooltip: _isFlagged ? 'Unflag' : 'Flag', - onPressed: () async { - final next = !_isFlagged; - await repo.setFlag(widget.emailId, flagged: next); - if (mounted) setState(() => _isFlagged = next); - }, - ), - IconButton( - icon: const Icon(Icons.drive_file_move_outline), - tooltip: 'Move to folder', - onPressed: header == null ? null : () => _moveTo(context, header), - ), - IconButton( - icon: const Icon(Icons.access_time), - tooltip: 'Snooze', - onPressed: header == null ? null : () => _snooze(context, header), - ), - IconButton( - icon: const Icon(Icons.report_outlined), - tooltip: 'Mark as spam', + icon: const Icon(Icons.archive), + tooltip: 'Archive', onPressed: header == null ? null : () { - unawaited(_markAsSpam(context, header)); + unawaited(_archive(context, header)); }, ), IconButton( @@ -148,8 +119,43 @@ class _EmailDetailScreenState extends ConsumerState { if (context.mounted) context.pop(); }, ), + IconButton( + icon: const Icon(Icons.report_outlined), + tooltip: 'Mark as spam', + onPressed: header == null + ? null + : () { + unawaited(_markAsSpam(context, header)); + }, + ), + IconButton( + icon: const Icon(Icons.drive_file_move_outline), + tooltip: 'Move to folder', + onPressed: header == null ? null : () => _moveTo(context, header), + ), + IconButton( + icon: const Icon(Icons.access_time), + tooltip: 'Snooze', + onPressed: header == null ? null : () => _snooze(context, header), + ), + IconButton( + icon: Icon( + _isFlagged ? Icons.star : Icons.star_border, + color: _isFlagged ? Colors.amber : null, + ), + tooltip: _isFlagged ? 'Unflag' : 'Flag', + onPressed: () async { + final next = !_isFlagged; + await repo.setFlag(widget.emailId, flagged: next); + if (mounted) setState(() => _isFlagged = next); + }, + ), PopupMenuButton( itemBuilder: (ctx) => [ + const PopupMenuItem( + value: 'mark_unread', + child: Text('Mark as unread'), + ), const PopupMenuItem( value: 'headers', child: Text('Show Mail Headers'), @@ -163,8 +169,11 @@ class _EmailDetailScreenState extends ConsumerState { child: Text('Show Raw Email'), ), ], - onSelected: (value) { - if (value == 'headers' && body != null) { + onSelected: (value) async { + if (value == 'mark_unread') { + await repo.setFlag(widget.emailId, seen: false); + if (context.mounted) context.pop(); + } else if (value == 'headers' && body != null) { _showHeaders(context, body); } else if (value == 'structure' && body != null) { _showStructure(context, body); @@ -393,21 +402,22 @@ class _EmailDetailScreenState extends ConsumerState { ); } - Future _markAsSpam(BuildContext context, Email header) async { - final mailboxRepo = ref.read(mailboxRepositoryProvider); - final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk'); + Future _archive(BuildContext context, Email header) async { + final mailbox = await resolveMailboxByRole( + context, + ref.read(mailboxRepositoryProvider), + header.accountId, + header.mailboxPath, + 'archive', + dialogTitle: 'No archive folder found', + createFolderName: 'Archive', + ); - if (junk == null) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No Junk folder found')), - ); - return; - } + if (mailbox == null || !context.mounted) return; await ref .read(emailRepositoryProvider) - .moveEmail(widget.emailId, junk.path); + .moveEmail(widget.emailId, mailbox.path); unawaited( ref.read(undoServiceProvider.notifier).pushAction( @@ -417,7 +427,40 @@ class _EmailDetailScreenState extends ConsumerState { type: UndoType.move, emailIds: [widget.emailId], sourceMailboxPath: header.mailboxPath, - destinationMailboxPath: junk.path, + destinationMailboxPath: mailbox.path, + ), + ), + ); + + if (context.mounted) context.pop(); + } + + Future _markAsSpam(BuildContext context, Email header) async { + final mailbox = await resolveMailboxByRole( + context, + ref.read(mailboxRepositoryProvider), + header.accountId, + header.mailboxPath, + 'junk', + dialogTitle: 'No spam folder found', + createFolderName: 'Junk', + ); + + if (mailbox == null || !context.mounted) return; + + await ref + .read(emailRepositoryProvider) + .moveEmail(widget.emailId, mailbox.path); + + unawaited( + ref.read(undoServiceProvider.notifier).pushAction( + UndoAction( + id: DateTime.now().toIso8601String(), + accountId: header.accountId, + type: UndoType.move, + emailIds: [widget.emailId], + sourceMailboxPath: header.mailboxPath, + destinationMailboxPath: mailbox.path, ), ), ); @@ -895,10 +938,13 @@ class _UnsubscribeChip extends StatelessWidget { Widget build(BuildContext context) { final uri = _parseUnsubscribeUri(header); if (uri == null) return const SizedBox.shrink(); - return ActionChip( - avatar: const Icon(Icons.unsubscribe_outlined, size: 16), - label: const Text('Unsubscribe'), - onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), + return Tooltip( + message: uri.toString(), + child: ActionChip( + avatar: const Icon(Icons.unsubscribe_outlined, size: 16), + label: const Text('Unsubscribe'), + onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), + ), ); } } diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 485e1a0..a10e85a 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -7,10 +7,11 @@ import 'package:intl/intl.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; -import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; +import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; @@ -25,8 +26,6 @@ int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day; String _fmtDate(DateTime dt) => _formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt); -enum _MissingFolderChoice { chooseExisting, createNew } - class EmailListScreen extends ConsumerStatefulWidget { const EmailListScreen({ super.key, @@ -150,16 +149,21 @@ class _EmailListScreenState extends ConsumerState { Widget build(BuildContext context) { final repo = ref.watch(emailRepositoryProvider); final accountAsync = ref.watch(accountByIdProvider(widget.accountId)); + final prefs = + ref.watch(userPreferencesProvider).value ?? const UserPreferences(); + final menuAtBottom = prefs.menuPosition == MenuPosition.bottom; return Scaffold( - appBar: _buildAppBar(repo, accountAsync), + appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom), drawer: _selecting ? null : FolderDrawer( accountId: widget.accountId, currentMailboxPath: widget.mailboxPath, ), - bottomNavigationBar: _selecting ? _selectionBottomBar() : null, + bottomNavigationBar: _selecting + ? _selectionBottomBar() + : (menuAtBottom ? _folderNavBottomBar() : null), body: Column( children: [ _buildSyncErrorBanner(), @@ -175,12 +179,14 @@ class _EmailListScreenState extends ConsumerState { PreferredSizeWidget _buildAppBar( EmailRepository emailRepo, - AsyncValue accountAsync, - ) { + AsyncValue accountAsync, { + required bool menuAtBottom, + }) { final selectionCount = _searching ? _selectedSearchIds.length : _selectedThreadIds.length; return AppBar( + automaticallyImplyLeading: !menuAtBottom, leading: _selecting ? IconButton( icon: const Icon(Icons.close), @@ -303,6 +309,22 @@ class _EmailListScreenState extends ConsumerState { ); } + Widget _folderNavBottomBar() { + return BottomAppBar( + child: Row( + children: [ + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + tooltip: 'Open folders', + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), + ], + ), + ); + } + Widget _selectionBottomBar() { return BottomAppBar( child: Row( @@ -431,70 +453,17 @@ class _EmailListScreenState extends ConsumerState { final ids = _selectedEmailIds; _clearSelection(); - final mailboxRepo = ref.read(mailboxRepositoryProvider); - Mailbox? mailbox = - await mailboxRepo.findMailboxByRole(widget.accountId, role); + final mailbox = await resolveMailboxByRole( + context, + ref.read(mailboxRepositoryProvider), + widget.accountId, + widget.mailboxPath, + role, + dialogTitle: dialogTitle, + createFolderName: createFolderName, + ); - if (!mounted) return; - - if (mailbox == null) { - final choice = await showDialog<_MissingFolderChoice>( - context: context, - builder: (ctx) => AlertDialog( - title: Text(dialogTitle), - actions: [ - TextButton( - onPressed: () => - Navigator.pop(ctx, _MissingFolderChoice.chooseExisting), - child: const Text('Choose existing folder'), - ), - FilledButton( - onPressed: () => - Navigator.pop(ctx, _MissingFolderChoice.createNew), - child: Text('Create "$createFolderName"'), - ), - ], - ), - ); - if (!mounted || choice == null) return; - - switch (choice) { - case _MissingFolderChoice.chooseExisting: - final mailboxes = - await mailboxRepo.observeMailboxes(widget.accountId).first; - if (!mounted) return; - final chosen = await showModalBottomSheet( - context: context, - builder: (ctx) => ListView( - shrinkWrap: true, - children: [ - const ListTile( - title: Text( - 'Move to…', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - for (final m - in mailboxes.where((m) => m.path != widget.mailboxPath)) - ListTile( - leading: const Icon(Icons.folder_outlined), - title: Text(m.name), - onTap: () => Navigator.pop(ctx, m.path), - ), - ], - ), - ); - if (chosen == null || !mounted) return; - mailbox = mailboxes.firstWhere((m) => m.path == chosen); - case _MissingFolderChoice.createNew: - mailbox = await mailboxRepo.createMailboxWithRole( - widget.accountId, - createFolderName, - role, - ); - if (!mounted) return; - } - } + if (!mounted || mailbox == null) return; final repo = ref.read(emailRepositoryProvider); diff --git a/lib/ui/screens/mailbox_list_screen.dart b/lib/ui/screens/mailbox_list_screen.dart index e0417fe..47fc231 100644 --- a/lib/ui/screens/mailbox_list_screen.dart +++ b/lib/ui/screens/mailbox_list_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; +import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; @@ -17,8 +18,12 @@ class MailboxListScreen extends ConsumerWidget { final mailboxRepo = ref.watch(mailboxRepositoryProvider); final emailRepo = ref.watch(emailRepositoryProvider); final accountAsync = ref.watch(accountByIdProvider(accountId)); + final prefs = + ref.watch(userPreferencesProvider).value ?? const UserPreferences(); + final menuAtBottom = prefs.menuPosition == MenuPosition.bottom; return Scaffold( appBar: AppBar( + automaticallyImplyLeading: !menuAtBottom, title: const Text('Folders'), actions: [ IconButton( @@ -42,6 +47,19 @@ class MailboxListScreen extends ConsumerWidget { ], ), drawer: FolderDrawer(accountId: accountId), + bottomNavigationBar: menuAtBottom + ? BottomAppBar( + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.menu), + tooltip: 'Open folders', + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ], + ), + ) + : null, body: Column( children: [ // ── Failed-mutation banner ─────────────────────────────────────── diff --git a/lib/ui/screens/user_preferences_screen.dart b/lib/ui/screens/user_preferences_screen.dart new file mode 100644 index 0000000..af18ffe --- /dev/null +++ b/lib/ui/screens/user_preferences_screen.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:sharedinbox/core/models/user_preferences.dart'; +import 'package:sharedinbox/di.dart'; + +class UserPreferencesScreen extends ConsumerWidget { + const UserPreferencesScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final prefsAsync = ref.watch(userPreferencesProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Preferences')), + body: prefsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => + const Center(child: Text('Error loading preferences')), + data: (prefs) => ListView( + children: [ + ListTile( + title: Text( + 'Menu bar position', + style: Theme.of(context).textTheme.titleSmall, + ), + subtitle: const Text( + 'Where the folder navigation menu is shown in the mailbox view.', + ), + ), + RadioGroup( + groupValue: prefs.menuPosition, + onChanged: (value) { + if (value == null) return; + unawaited( + ref + .read(userPreferencesRepositoryProvider) + .updateMenuPosition(value), + ); + }, + child: const Column( + children: [ + RadioListTile( + title: Text('Bottom (default)'), + subtitle: Text( + 'Open folder navigation from a button at the bottom of the screen.', + ), + value: MenuPosition.bottom, + ), + RadioListTile( + title: Text('Top'), + subtitle: Text( + 'Open folder navigation from the hamburger icon in the top bar.', + ), + value: MenuPosition.top, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/widgets/secure_email_webview.dart b/lib/ui/widgets/secure_email_webview.dart index b85a657..d079a48 100644 --- a/lib/ui/widgets/secure_email_webview.dart +++ b/lib/ui/widgets/secure_email_webview.dart @@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) { diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index bb03fe8..931bb8a 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -20,7 +20,9 @@ const _noCode = { 'lib/core/repositories/sync_log_repository.dart', 'lib/core/repositories/undo_repository.dart', 'lib/core/repositories/search_history_repository.dart', + 'lib/core/repositories/user_preferences_repository.dart', 'lib/core/models/undo_action.dart', + 'lib/core/models/user_preferences.dart', 'lib/core/storage/secure_storage.dart', }; @@ -58,6 +60,7 @@ const _excluded = { 'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/undo_shell.dart', 'lib/ui/screens/about_screen.dart', + 'lib/ui/screens/email_action_helpers.dart', 'lib/ui/utils/about_markdown.dart', 'lib/ui/widgets/email_tile.dart', 'lib/core/sync/account_sync_manager.dart', @@ -72,6 +75,8 @@ const _excluded = { 'lib/data/repositories/sync_log_repository_impl.dart', 'lib/data/repositories/undo_repository_impl.dart', 'lib/data/repositories/search_history_repository_impl.dart', + 'lib/data/repositories/user_preferences_repository_impl.dart', + 'lib/ui/screens/user_preferences_screen.dart', 'lib/core/services/update_service.dart', }; diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 97bad71..aff972b 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 33); + expect(db.schemaVersion, 34); await db.close(); }); @@ -199,6 +199,9 @@ void main() { expect(syncLogColumns, contains('error_stack_trace')); expect(syncLogColumns, contains('is_permanent')); + // v34: user_preferences table. + await db.customSelect('SELECT count(*) FROM user_preferences').get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); @@ -391,11 +394,14 @@ void main() { expect(syncLogColumns, contains('error_stack_trace')); expect(syncLogColumns, contains('is_permanent')); + // v34: user_preferences table. + await db.customSelect('SELECT count(*) FROM user_preferences').get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); - test('fresh install creates all tables at schemaVersion 33', () async { + test('fresh install creates all tables at schemaVersion 34', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get(); @@ -422,6 +428,7 @@ void main() { 'local_sieve_scripts', // v29 'share_keys', // v31 'local_sieve_applied', // v32 + 'user_preferences', // v34 ]), ); diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index 638f675..ba52d33 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow; import 'helpers.dart'; @@ -206,5 +207,50 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('sharedinbox.de'), findsOneWidget); }); + + testWidgets('shows Healthy when sync health is healthy', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts', + overrides: baseOverrides( + accounts: [kTestAccount], + syncHealth: SyncHealthRow( + accountId: kTestAccount.id, + lastVerifiedAt: DateTime(2024, 6), + isHealthy: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Healthy'), findsOneWidget); + }); + + testWidgets( + 'shows discrepancy details when sync health has discrepancies', + (tester) async { + const summary = + '{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}'; + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts', + overrides: baseOverrides( + accounts: [kTestAccount], + syncHealth: SyncHealthRow( + accountId: kTestAccount.id, + lastVerifiedAt: DateTime(2024, 6), + isHealthy: false, + discrepancySummary: summary, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('missing locally: 3'), findsOneWidget); + expect(find.textContaining('flag mismatches: 1'), findsOneWidget); + }, + ); }); } diff --git a/test/widget/email_detail_screen_test.dart b/test/widget/email_detail_screen_test.dart index d1368bb..ec4f96e 100644 --- a/test/widget/email_detail_screen_test.dart +++ b/test/widget/email_detail_screen_test.dart @@ -290,11 +290,10 @@ void main() { ); }); - testWidgets( - 'Mark as spam moves email to junk and shows snackbar when no junk folder', + testWidgets('Mark as spam shows dialog when no junk folder', (tester) async { // FakeMailboxRepository has no mailboxes by default → findMailboxByRole - // returns null → snackbar shown. + // returns null → dialog shown. await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', @@ -312,7 +311,76 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('No Junk folder found'), findsOneWidget); + expect(find.text('No spam folder found'), findsOneWidget); + }); + + testWidgets('Archive button is present in app bar', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Archive', + ), + findsOneWidget, + ); + }); + + testWidgets('Archive shows dialog when no archive folder', (tester) async { + // FakeMailboxRepository has no mailboxes by default → findMailboxByRole + // returns null → dialog shown. + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Archive', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No archive folder found'), findsOneWidget); + }); + + testWidgets('Mark as unread is in popup menu, not a standalone button', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + // No standalone icon button for mark as unread. + expect( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Mark as unread', + ), + findsNothing, + ); + + // It appears in the popup menu. + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + + expect(find.text('Mark as unread'), findsOneWidget); }); testWidgets('Show Raw Email dialog shows size of email', (tester) async { @@ -407,6 +475,44 @@ void main() { expect(find.text('Share'), findsOneWidget); }); + testWidgets( + 'long-press on unsubscribe chip shows URL tooltip', + (tester) async { + final email = testEmail( + listUnsubscribeHeader: '', + ); + await tester.pumpWidget( + buildApp( + initialLocation: + '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + email: email, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Unsubscribe'), findsOneWidget); + + expect( + find.byWidgetPredicate( + (w) => + w is Tooltip && w.message == 'https://example.com/unsubscribe', + ), + findsOneWidget, + ); + + await tester.longPress(find.text('Unsubscribe')); + await tester.pumpAndSettle(); + + expect( + find.text('https://example.com/unsubscribe'), + findsOneWidget, + ); + }, + ); + testWidgets('Show Mail Structure opens dialog with MIME parts', ( tester, ) async { diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 0798258..3bfca9a 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -316,7 +316,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('INBOX'), findsOneWidget); - expect(find.byType(BottomAppBar), findsNothing); + expect(find.byIcon(Icons.close), findsNothing); }); testWidgets('tapping clear icon in search bar clears results', ( diff --git a/test/widget/goldens/email_list_empty.png b/test/widget/goldens/email_list_empty.png index 8d2a371..f220494 100644 Binary files a/test/widget/goldens/email_list_empty.png and b/test/widget/goldens/email_list_empty.png differ diff --git a/test/widget/goldens/email_list_error_banner.png b/test/widget/goldens/email_list_error_banner.png index 6e00942..2baf581 100644 Binary files a/test/widget/goldens/email_list_error_banner.png and b/test/widget/goldens/email_list_error_banner.png differ diff --git a/test/widget/goldens/email_list_search_results.png b/test/widget/goldens/email_list_search_results.png index 47bb8c2..5e2f692 100644 Binary files a/test/widget/goldens/email_list_search_results.png and b/test/widget/goldens/email_list_search_results.png differ diff --git a/test/widget/goldens/email_list_with_emails.png b/test/widget/goldens/email_list_with_emails.png index cc7887e..604b859 100644 Binary files a/test/widget/goldens/email_list_with_emails.png and b/test/widget/goldens/email_list_with_emails.png differ diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index d5ff81e..208ab0d 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -14,6 +14,7 @@ import 'package:sharedinbox/core/models/discovery_result.dart'; import 'package:sharedinbox/core/models/draft.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; +import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; @@ -21,10 +22,12 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; +import 'package:sharedinbox/core/repositories/user_preferences_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/share_encryption_service.dart'; +import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; @@ -38,6 +41,7 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart'; import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart'; import 'package:sharedinbox/ui/screens/search_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; +import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; // --------------------------------------------------------------------------- // Fake repositories @@ -430,6 +434,10 @@ Widget buildApp({ path: 'send', builder: (ctx, state) => const AccountSendScreen(), ), + GoRoute( + path: 'preferences', + builder: (ctx, state) => const UserPreferencesScreen(), + ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( @@ -505,16 +513,18 @@ Widget buildApp({ return ProviderScope( // Defaults come first so tests can override them via [overrides]. // - // syncHealthProvider and syncLogRepositoryProvider are backed by Drift - // StreamQueries. When a StreamProvider that wraps a Drift query is disposed, - // Drift schedules a Timer.run() for cache debouncing. Flutter's test - // framework then fails the test with "A Timer is still pending". Replacing - // these with simple synchronous streams avoids the pending-timer assertion. + // syncLogRepositoryProvider is backed by a Drift StreamQuery. When the + // provider is disposed, Drift schedules a Timer.run() for cache + // debouncing. Flutter's test framework then fails the test with "A Timer + // is still pending". Replacing it with a synchronous stream avoids this. + // syncHealthProvider has the same issue and is overridden in baseOverrides. overrides: [ - syncHealthProvider.overrideWith((ref, _) => Stream.value(null)), syncLogRepositoryProvider.overrideWithValue( const NoOpSyncLogRepository(), ), + userPreferencesRepositoryProvider.overrideWithValue( + FakeUserPreferencesRepository(), + ), ...overrides, manageSieveProbeServiceProvider.overrideWith( (ref) => _NoOpManageSieveProbeService(), @@ -541,6 +551,7 @@ List baseOverrides({ Exception? connectionError, ShareKeyRepository? shareKeyRepository, bool hasStoredPassword = true, + SyncHealthRow? syncHealth, }) => [ accountRepositoryProvider.overrideWithValue( @@ -559,6 +570,9 @@ List baseOverrides({ shareKeyRepositoryProvider.overrideWithValue( shareKeyRepository ?? FakeShareKeyRepository(), ), + // syncHealthProvider is backed by a Drift StreamQuery; override with a + // plain stream to avoid "A Timer is still pending" in tests. + syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)), ]; // --------------------------------------------------------------------------- @@ -588,6 +602,7 @@ Email testEmail({ bool isSeen = false, bool isFlagged = false, bool hasAttachment = false, + String? listUnsubscribeHeader, }) => Email( id: id, @@ -603,8 +618,26 @@ Email testEmail({ isSeen: isSeen, isFlagged: isFlagged, hasAttachment: hasAttachment, + listUnsubscribeHeader: listUnsubscribeHeader, ); +class FakeUserPreferencesRepository implements UserPreferencesRepository { + FakeUserPreferencesRepository({ + this.menuPosition = MenuPosition.bottom, + }); + + MenuPosition menuPosition; + + @override + Stream observePreferences() => + Stream.value(UserPreferences(menuPosition: menuPosition)); + + @override + Future updateMenuPosition(MenuPosition position) async { + menuPosition = position; + } +} + class FakeSearchHistoryRepository implements SearchHistoryRepository { final List _history = []; diff --git a/test/widget/secure_email_webview_test.dart b/test/widget/secure_email_webview_test.dart index e214a13..0871966 100644 --- a/test/widget/secure_email_webview_test.dart +++ b/test/widget/secure_email_webview_test.dart @@ -41,6 +41,20 @@ void main() { expect(html, contains('https: http: data: blob:')); _expectLightMode(html); }); + + test('prevents horizontal overflow so wide HTML emails are not cut off', + () { + final html = + buildEmailHtml('
x
'); + // Body clips overflow so fixed-width email tables don't escape the viewport. + expect(html, contains('overflow-x: hidden')); + // Tables are forced to full viewport width so fixed pixel widths don't overflow. + expect(html, contains('table { width: 100%')); + // All elements are capped at viewport width via max-width. + expect(html, contains('max-width: 100%')); + // Pre-formatted text wraps instead of stretching the page. + expect(html, contains('white-space: pre-wrap')); + }); }); // On Linux (the test host) the widget falls back to plain text extracted via diff --git a/test/widget/user_preferences_screen_test.dart b/test/widget/user_preferences_screen_test.dart new file mode 100644 index 0000000..61ff92f --- /dev/null +++ b/test/widget/user_preferences_screen_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sharedinbox/core/models/user_preferences.dart'; +import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; + +import 'helpers.dart'; + +void main() { + group('UserPreferencesScreen', () { + testWidgets('shows both menu position options', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/preferences', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Menu bar position'), findsOneWidget); + expect(find.text('Bottom (default)'), findsOneWidget); + expect(find.text('Top'), findsOneWidget); + }); + + testWidgets('bottom option is selected by default', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/preferences', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + final radioGroup = find.byType(RadioGroup); + final widget = tester.widget>(radioGroup); + expect(widget.groupValue, MenuPosition.bottom); + }); + + testWidgets('tapping Top option updates the repo', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/preferences', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Top')); + await tester.pumpAndSettle(); + + final repo = ProviderScope.containerOf( + tester.element(find.byType(UserPreferencesScreen)), + ).read(userPreferencesRepositoryProvider) + as FakeUserPreferencesRepository; + + expect(repo.menuPosition, MenuPosition.top); + }); + }); +}