Closes #373 ## Summary - **Schema v38**: two new columns on `user_preferences` — `prefetch_mode` (default `wifiOnly`) and `body_cache_limit_mb` (default 100 MB). - **`BodyCacheService`**: queries for emails that have no cached body, fetches them newest-first in batches of 20, and evicts the oldest cached bodies when the configured size limit is exceeded. - **Separate WorkManager task** (`si_bg_prefetch`): runs hourly with `NetworkType.unmetered` (Wi-Fi) or `NetworkType.connected` (any) depending on the user's choice. The task is cancelled when prefetch is disabled. - **App startup**: reads the stored preference from the DB and re-registers the WorkManager task with the correct constraint. - **Preferences screen**: radio group for prefetch mode (Wi-Fi only / Any network / Disabled) and a dropdown for cache size limit (50 / 100 / 200 / 500 MB). ## What is NOT downloaded Binary attachments are never fetched — `getEmailBody()` stores only `textBody` and `htmlBody`. The cache size limit + per-run batch cap (20 emails) keep storage bounded even on large mailboxes. ## Test plan - [x] `task analyze` — no issues - [x] `task test` — all 492 tests pass (incl. updated migration_test.dart for v38) Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de> Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/400
265 lines
9.7 KiB
Dart
265 lines
9.7 KiB
Dart
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/core/sync/background_sync.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);
|
|
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
|
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
title: Text(
|
|
'Offline email cache',
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
subtitle: const Text(
|
|
'Pre-fetch email bodies in the background so they are available offline.',
|
|
),
|
|
),
|
|
RadioGroup<PrefetchMode>(
|
|
groupValue: prefs.prefetchMode,
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
unawaited(
|
|
ref
|
|
.read(userPreferencesRepositoryProvider)
|
|
.updatePrefetchMode(value),
|
|
);
|
|
unawaited(registerBodyPrefetchTask(value));
|
|
},
|
|
child: const Column(
|
|
children: [
|
|
RadioListTile<PrefetchMode>(
|
|
title: Text('Wi-Fi only (default)'),
|
|
subtitle: Text(
|
|
'Pre-fetch bodies in the background when connected to Wi-Fi.',
|
|
),
|
|
value: PrefetchMode.wifiOnly,
|
|
),
|
|
RadioListTile<PrefetchMode>(
|
|
title: Text('Any network'),
|
|
subtitle: Text(
|
|
'Pre-fetch bodies on Wi-Fi and mobile data.',
|
|
),
|
|
value: PrefetchMode.always,
|
|
),
|
|
RadioListTile<PrefetchMode>(
|
|
title: Text('Disabled'),
|
|
subtitle: Text(
|
|
'Do not pre-fetch email bodies in the background.',
|
|
),
|
|
value: PrefetchMode.disabled,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
|
|
const SizedBox(height: 4),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
const Text('Cache size limit:'),
|
|
const SizedBox(width: 16),
|
|
DropdownButton<int>(
|
|
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
|
|
items: const [
|
|
DropdownMenuItem(value: 50, child: Text('50 MB')),
|
|
DropdownMenuItem(value: 100, child: Text('100 MB')),
|
|
DropdownMenuItem(value: 200, child: Text('200 MB')),
|
|
DropdownMenuItem(value: 500, child: Text('500 MB')),
|
|
],
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
unawaited(
|
|
ref
|
|
.read(userPreferencesRepositoryProvider)
|
|
.updateBodyCacheLimitMb(value),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
const Divider(),
|
|
ListTile(
|
|
title: Text(
|
|
'Trusted image senders',
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
subtitle: const Text(
|
|
'Remote images are loaded automatically for these senders.',
|
|
),
|
|
),
|
|
...trustedSendersAsync.when(
|
|
loading: () => const [],
|
|
error: (_, __) => const [],
|
|
data: (senders) => senders.isEmpty
|
|
? [
|
|
const Padding(
|
|
padding:
|
|
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Text('No trusted senders yet.'),
|
|
),
|
|
]
|
|
: [
|
|
for (final sender in senders)
|
|
ListTile(
|
|
title: Text(sender),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: 'Remove',
|
|
onPressed: () {
|
|
unawaited(
|
|
ref
|
|
.read(userPreferencesRepositoryProvider)
|
|
.removeTrustedImageSender(sender),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
int _nearestCacheOption(int mb) {
|
|
const options = [50, 100, 200, 500];
|
|
return options.reduce(
|
|
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
|
|
);
|
|
}
|
|
}
|