Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
badc449866 | ||
|
|
a56847fdb5 | ||
|
|
ef3d278c5f | ||
|
|
e1ccfabfdd | ||
|
|
41550eb4b5 | ||
|
|
633fc5d9da | ||
|
|
14f64cd2a5 | ||
|
|
5ddfe68467 |
@@ -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();
|
||||
|
||||
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 33;
|
||||
const int dbSchemaVersion = 36;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
enum MenuPosition { bottom, top }
|
||||
|
||||
enum AfterMailViewAction { nextMessage, showMailbox }
|
||||
|
||||
class UserPreferences {
|
||||
const UserPreferences({
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||
});
|
||||
final MenuPosition menuPosition;
|
||||
final MenuPosition mailViewButtonPosition;
|
||||
final AfterMailViewAction afterMailViewAction;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
|
||||
abstract class UserPreferencesRepository {
|
||||
Stream<UserPreferences> observePreferences();
|
||||
Future<void> updateMenuPosition(MenuPosition position);
|
||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||
}
|
||||
@@ -307,6 +307,23 @@ class LocalSieveApplied extends Table {
|
||||
Set<Column> 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'))();
|
||||
// Added in schema v35: 'bottom' (default) | 'top'
|
||||
TextColumn get mailViewButtonPosition =>
|
||||
text().withDefault(const Constant('bottom'))();
|
||||
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
||||
TextColumn get afterMailViewAction =>
|
||||
text().withDefault(const Constant('nextMessage'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@DriftDatabase(
|
||||
@@ -327,6 +344,7 @@ class LocalSieveApplied extends Table {
|
||||
LocalSieveScripts,
|
||||
LocalSieveApplied,
|
||||
ShareKeys,
|
||||
UserPreferences,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -578,6 +596,21 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
if (from < 34) {
|
||||
await m.createTable(userPreferences);
|
||||
}
|
||||
if (from >= 34 && from < 35) {
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.mailViewButtonPosition,
|
||||
);
|
||||
}
|
||||
if (from >= 34 && from < 36) {
|
||||
await m.addColumn(
|
||||
userPreferences,
|
||||
userPreferences.afterMailViewAction,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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<pref.UserPreferences> observePreferences() {
|
||||
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
|
||||
.watchSingleOrNull()
|
||||
.map(_rowToModel);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMenuPosition(pref.MenuPosition position) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
menuPosition: Value(position.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
mailViewButtonPosition: Value(position.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAfterMailViewAction(
|
||||
pref.AfterMailViewAction action,
|
||||
) async {
|
||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||
UserPreferencesCompanion(
|
||||
id: const Value(_rowId),
|
||||
afterMailViewAction: Value(action.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,
|
||||
),
|
||||
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
|
||||
(e) => e.name == row.mailViewButtonPosition,
|
||||
orElse: () => pref.MenuPosition.bottom,
|
||||
),
|
||||
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
|
||||
(e) => e.name == row.afterMailViewAction,
|
||||
orElse: () => pref.AfterMailViewAction.nextMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<UserPreferencesRepository>((ref) {
|
||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final userPreferencesProvider =
|
||||
StreamProvider.autoDispose<UserPreferences>((ref) {
|
||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String, dynamic>;
|
||||
var missingLocally = 0;
|
||||
var missingOnServer = 0;
|
||||
var flagMismatches = 0;
|
||||
for (final v in decoded.values) {
|
||||
final m = v as Map<String, dynamic>;
|
||||
missingLocally += (m['missingLocally'] as int? ?? 0);
|
||||
missingOnServer += (m['missingOnServer'] as int? ?? 0);
|
||||
flagMismatches += (m['flagMismatches'] as int? ?? 0);
|
||||
}
|
||||
final parts = <String>[];
|
||||
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();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
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/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
@@ -98,6 +99,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
onPressed: () async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
|
||||
if (header != null) {
|
||||
@@ -116,7 +118,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -171,8 +173,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
],
|
||||
onSelected: (value) async {
|
||||
if (value == 'mark_unread') {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
} else if (value == 'headers' && body != null) {
|
||||
_showHeaders(context, body);
|
||||
} else if (value == 'structure' && body != null) {
|
||||
@@ -252,6 +255,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
|
||||
if (header == null) return null;
|
||||
final prefs = ref.read(userPreferencesProvider).value;
|
||||
final action =
|
||||
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||
if (action != AfterMailViewAction.nextMessage) return null;
|
||||
|
||||
final threads = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.observeThreads(header.accountId, header.mailboxPath)
|
||||
.first;
|
||||
|
||||
final currentIndex =
|
||||
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
|
||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
||||
return threads[currentIndex + 1].latestEmailId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
|
||||
if (!context.mounted) return;
|
||||
if (nextEmailId != null && header != null) {
|
||||
context.go(
|
||||
'/accounts/${header.accountId}'
|
||||
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(nextEmailId)}',
|
||||
);
|
||||
} else {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
||||
setState(() => _downloading.add(att.filename));
|
||||
try {
|
||||
@@ -403,6 +439,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _archive(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
@@ -432,10 +471,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
@@ -465,7 +507,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _forward(
|
||||
@@ -490,6 +532,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _moveTo(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final mailboxes =
|
||||
await mailboxRepo.observeMailboxes(header.accountId).first;
|
||||
@@ -538,10 +582,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _snooze(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final until = await showModalBottomSheet<DateTime>(
|
||||
context: context,
|
||||
builder: (ctx) => const SnoozePicker(),
|
||||
@@ -569,7 +616,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
_navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,10 +985,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
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/email_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||
@@ -148,16 +149,21 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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(),
|
||||
@@ -173,12 +179,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
PreferredSizeWidget _buildAppBar(
|
||||
EmailRepository emailRepo,
|
||||
AsyncValue<Account?> accountAsync,
|
||||
) {
|
||||
AsyncValue<Account?> accountAsync, {
|
||||
required bool menuAtBottom,
|
||||
}) {
|
||||
final selectionCount =
|
||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: !menuAtBottom,
|
||||
leading: _selecting
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
@@ -301,6 +309,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
|
||||
|
||||
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/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Thread')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Thread'),
|
||||
automaticallyImplyLeading: !buttonAtBottom,
|
||||
),
|
||||
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
|
||||
body: StreamBuilder<List<Email>>(
|
||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||
builder: (context, snapshot) {
|
||||
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButtonBar(BuildContext context) {
|
||||
return BottomAppBar(
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Back',
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailMessageCard extends ConsumerStatefulWidget {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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<MenuPosition>(
|
||||
groupValue: prefs.menuPosition,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.updateMenuPosition(value),
|
||||
);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<MenuPosition>(
|
||||
title: Text('Bottom (default)'),
|
||||
subtitle: Text(
|
||||
'Open folder navigation from a button at the bottom of the screen.',
|
||||
),
|
||||
value: MenuPosition.bottom,
|
||||
),
|
||||
RadioListTile<MenuPosition>(
|
||||
title: Text('Top'),
|
||||
subtitle: Text(
|
||||
'Open folder navigation from the hamburger icon in the top bar.',
|
||||
),
|
||||
value: MenuPosition.top,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Single mail view button position',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Where the back button is shown in the single mail view.',
|
||||
),
|
||||
),
|
||||
RadioGroup<MenuPosition>(
|
||||
groupValue: prefs.mailViewButtonPosition,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.updateMailViewButtonPosition(value),
|
||||
);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<MenuPosition>(
|
||||
title: Text('Bottom (default)'),
|
||||
subtitle: Text(
|
||||
'Show the back button at the bottom of the screen.',
|
||||
),
|
||||
value: MenuPosition.bottom,
|
||||
),
|
||||
RadioListTile<MenuPosition>(
|
||||
title: Text('Top'),
|
||||
subtitle: Text(
|
||||
'Show the back button in the top bar.',
|
||||
),
|
||||
value: MenuPosition.top,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'After mail action',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: const Text(
|
||||
'What to show after deleting, archiving, or otherwise handling a message.',
|
||||
),
|
||||
),
|
||||
RadioGroup<AfterMailViewAction>(
|
||||
groupValue: prefs.afterMailViewAction,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.updateAfterMailViewAction(value),
|
||||
);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
title: Text('Next message (default)'),
|
||||
subtitle: Text(
|
||||
'Show the next message in the mailbox.',
|
||||
),
|
||||
value: AfterMailViewAction.nextMessage,
|
||||
),
|
||||
RadioListTile<AfterMailViewAction>(
|
||||
title: Text('Return to mailbox'),
|
||||
subtitle: Text(
|
||||
'Return to the message list.',
|
||||
),
|
||||
value: AfterMailViewAction.showMailbox,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -73,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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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, 36);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -199,6 +199,16 @@ 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();
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
@@ -391,11 +401,21 @@ 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();
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
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 36', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -422,6 +442,7 @@ void main() {
|
||||
'local_sieve_scripts', // v29
|
||||
'share_keys', // v31
|
||||
'local_sieve_applied', // v32
|
||||
'user_preferences', // v34
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -441,6 +462,13 @@ void main() {
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
// v35: mail_view_button_position column on user_preferences.
|
||||
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
|
||||
expect(userPrefsColumns, contains('mail_view_button_position'));
|
||||
|
||||
// v36: after_mail_view_action column on user_preferences.
|
||||
expect(userPrefsColumns, contains('after_mail_view_action'));
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -475,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: '<https://example.com/unsubscribe>',
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -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', (
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@@ -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
|
||||
@@ -410,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
|
||||
Widget buildApp({
|
||||
required String initialLocation,
|
||||
required List<Override> overrides,
|
||||
UserPreferencesRepository? userPreferences,
|
||||
}) {
|
||||
final testRouter = GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
@@ -430,6 +435,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 +514,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(
|
||||
userPreferences ?? FakeUserPreferencesRepository(),
|
||||
),
|
||||
...overrides,
|
||||
manageSieveProbeServiceProvider.overrideWith(
|
||||
(ref) => _NoOpManageSieveProbeService(),
|
||||
@@ -541,6 +552,7 @@ List<Override> baseOverrides({
|
||||
Exception? connectionError,
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
bool hasStoredPassword = true,
|
||||
SyncHealthRow? syncHealth,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
@@ -559,6 +571,9 @@ List<Override> 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 +603,7 @@ Email testEmail({
|
||||
bool isSeen = false,
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
String? listUnsubscribeHeader,
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
@@ -603,8 +619,45 @@ Email testEmail({
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||
);
|
||||
|
||||
class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
FakeUserPreferencesRepository({
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||
});
|
||||
|
||||
MenuPosition menuPosition;
|
||||
MenuPosition mailViewButtonPosition;
|
||||
AfterMailViewAction afterMailViewAction;
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
UserPreferences(
|
||||
menuPosition: menuPosition,
|
||||
mailViewButtonPosition: mailViewButtonPosition,
|
||||
afterMailViewAction: afterMailViewAction,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateMenuPosition(MenuPosition position) async {
|
||||
menuPosition = position;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
|
||||
mailViewButtonPosition = position;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
|
||||
afterMailViewAction = action;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
||||
final List<String> _history = [];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
import 'helpers.dart';
|
||||
@@ -142,6 +143,60 @@ void main() {
|
||||
expect(find.byIcon(Icons.expand_more), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows bottom app bar with back button by default', (
|
||||
tester,
|
||||
) async {
|
||||
final email = _threadEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BottomAppBar), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides bottom app bar when button position is top', (
|
||||
tester,
|
||||
) async {
|
||||
final email = _threadEmail();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
|
||||
userPreferences: FakeUserPreferencesRepository(
|
||||
mailViewButtonPosition: MenuPosition.top,
|
||||
),
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: [email]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BottomAppBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('flagged email shows star icon', (tester) async {
|
||||
final email = _threadEmail(isFlagged: true);
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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)'), findsNWidgets(2));
|
||||
expect(find.text('Top'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('shows single mail view button position section', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Single mail view button position'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('menu position bottom option is selected by default', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
||||
final menuGroup =
|
||||
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
|
||||
expect(menuGroup.groupValue, MenuPosition.bottom);
|
||||
});
|
||||
|
||||
testWidgets('mail view button position bottom is selected by default', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final radioGroups = find.byType(RadioGroup<MenuPosition>);
|
||||
final mailViewGroup =
|
||||
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
|
||||
expect(mailViewGroup.groupValue, MenuPosition.bottom);
|
||||
});
|
||||
|
||||
testWidgets('tapping Top in menu position section updates the repo', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Top').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.menuPosition, MenuPosition.top);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping Top in mail view button position section updates the repo', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Top').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.mailViewButtonPosition, MenuPosition.top);
|
||||
});
|
||||
|
||||
testWidgets('shows after mail action section', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Scroll down to reveal the new section below the fold.
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('After mail action'), findsOneWidget);
|
||||
expect(find.text('Next message (default)'), findsOneWidget);
|
||||
expect(find.text('Return to mailbox'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('after mail action next message is selected by default', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
|
||||
final group =
|
||||
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
|
||||
expect(group.groupValue, AfterMailViewAction.nextMessage);
|
||||
});
|
||||
|
||||
testWidgets('tapping Return to mailbox updates the repo', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Return to mailbox'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
|
||||
});
|
||||
});
|
||||
}
|
||||