feat: configurable next action after single mail view (#300) (#308)

This commit was merged in pull request #308.
This commit is contained in:
Bot of Thomas Güttler
2026-05-27 23:33:14 +02:00
parent 41550eb4b5
commit f0f210e5ab
12 changed files with 448 additions and 22 deletions
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 34;
const int dbSchemaVersion = 36;
+9 -1
View File
@@ -1,6 +1,14 @@
enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences {
const UserPreferences({this.menuPosition = MenuPosition.bottom});
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
}
@@ -3,4 +3,6 @@ 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);
}
+18
View File
@@ -313,6 +313,12 @@ 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};
@@ -593,6 +599,18 @@ class AppDatabase extends _$AppDatabase {
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,
);
}
},
);
}
@@ -26,6 +26,28 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
);
}
@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(
@@ -33,6 +55,14 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
(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,
),
);
}
}
+53 -6
View File
@@ -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);
}
}
+23 -1
View File
@@ -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 {
@@ -59,6 +59,84 @@ class UserPreferencesScreen extends ConsumerWidget {
],
),
),
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,
),
],
),
),
],
),
),
+23 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 34);
expect(db.schemaVersion, 36);
await db.close();
});
@@ -202,6 +202,13 @@ void main() {
// 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();
});
@@ -397,11 +404,18 @@ void main() {
// 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 34', () async {
test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -448,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();
});
});
+23 -3
View File
@@ -414,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
Widget buildApp({
required String initialLocation,
required List<Override> overrides,
UserPreferencesRepository? userPreferences,
}) {
final testRouter = GoRouter(
initialLocation: initialLocation,
@@ -523,7 +524,7 @@ Widget buildApp({
const NoOpSyncLogRepository(),
),
userPreferencesRepositoryProvider.overrideWithValue(
FakeUserPreferencesRepository(),
userPreferences ?? FakeUserPreferencesRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
@@ -624,18 +625,37 @@ Email testEmail({
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));
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 {
@@ -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(
+133 -8
View File
@@ -20,11 +20,13 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Menu bar position'), findsOneWidget);
expect(find.text('Bottom (default)'), findsOneWidget);
expect(find.text('Top'), findsOneWidget);
expect(find.text('Bottom (default)'), findsNWidgets(2));
expect(find.text('Top'), findsNWidgets(2));
});
testWidgets('bottom option is selected by default', (tester) async {
testWidgets('shows single mail view button position section', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
@@ -33,12 +35,15 @@ void main() {
);
await tester.pumpAndSettle();
final radioGroup = find.byType(RadioGroup<MenuPosition>);
final widget = tester.widget<RadioGroup<MenuPosition>>(radioGroup);
expect(widget.groupValue, MenuPosition.bottom);
expect(
find.text('Single mail view button position'),
findsOneWidget,
);
});
testWidgets('tapping Top option updates the repo', (tester) async {
testWidgets('menu position bottom option is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
@@ -47,7 +52,41 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top'));
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(
@@ -57,5 +96,91 @@ void main() {
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);
});
});
}