From fd4ce402d7e15755e955de69ccf92a47e09fe024 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 23:21:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(flutter/settings):=20Storage=20section=20U?= =?UTF-8?q?I=20=E2=80=94=20usage,=20cap,=20prefetch,=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New StorageSection widget mounted in SettingsScreen between Appearance and Password sections. Surfaces: - Cache usage display (live, refreshed on cap change + on Clear) - Cache size limit dropdown (1 / 5 / 10 / 25 GB / Unlimited) - Pre-fetch ahead dropdown (1 / 3 / 5 / 7 / 10 tracks) - Cache liked tracks switch - Clear cache button (confirm dialog, then clearAll) - Sync now button (triggers SyncController.sync(), spinner while inflight) Cap change triggers immediate eviction. Clear cache wipes EVERYTHING (manual-pinned tracks too) per the operator-facing copy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/settings/settings_screen.dart | 3 + .../lib/settings/storage_section.dart | 206 ++++++++++++++++++ .../test/settings/storage_section_test.dart | 71 ++++++ 3 files changed, 280 insertions(+) create mode 100644 flutter_client/lib/settings/storage_section.dart create mode 100644 flutter_client/test/settings/storage_section_test.dart diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart index 9202ad7c..24f3728d 100644 --- a/flutter_client/lib/settings/settings_screen.dart +++ b/flutter_client/lib/settings/settings_screen.dart @@ -9,6 +9,7 @@ import '../library/library_providers.dart' show dioProvider; import '../models/my_profile.dart'; import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; +import 'storage_section.dart'; import '../theme/theme_mode_provider.dart'; final _settingsApiProvider = FutureProvider((ref) async { @@ -48,6 +49,8 @@ class SettingsScreen extends ConsumerWidget { _Divider(), _AppearanceSection(), _Divider(), + StorageSection(), + _Divider(), _PasswordSection(), _Divider(), _ListenBrainzSection(), diff --git a/flutter_client/lib/settings/storage_section.dart b/flutter_client/lib/settings/storage_section.dart new file mode 100644 index 00000000..f5488195 --- /dev/null +++ b/flutter_client/lib/settings/storage_section.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../cache/audio_cache_manager.dart'; +import '../cache/cache_settings_provider.dart'; +import '../cache/sync_controller.dart'; +import '../theme/theme_extension.dart'; + +/// Settings card: usage display, cap selector, prefetch window selector, +/// cache-liked toggle, Clear cache + Sync now buttons. +class StorageSection extends ConsumerStatefulWidget { + const StorageSection({super.key}); + + @override + ConsumerState createState() => _StorageSectionState(); +} + +class _StorageSectionState extends ConsumerState { + int? _usageBytes; + bool _syncing = false; + + @override + void initState() { + super.initState(); + _refreshUsage(); + } + + Future _refreshUsage() async { + final mgr = ref.read(audioCacheManagerProvider); + final used = await mgr.usageBytes(); + if (mounted) setState(() => _usageBytes = used); + } + + String _fmtBytes(int? n) { + if (n == null) return '—'; + if (n == 0) return '0 B'; + if (n < 1024) return '$n B'; + if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB'; + if (n < 1024 * 1024 * 1024) return '${(n / 1024 / 1024).toStringAsFixed(1)} MB'; + return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB'; + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final settings = ref.watch(cacheSettingsProvider); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Card( + color: fs.iron, + child: Padding( + padding: const EdgeInsets.all(16), + child: settings.when( + loading: () => const Padding( + padding: EdgeInsets.all(8), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Text('$e', style: TextStyle(color: fs.error)), + data: (s) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Storage', + style: TextStyle( + color: fs.parchment, + fontSize: 18, + fontWeight: FontWeight.w500)), + const SizedBox(height: 12), + Row(children: [ + Text('Cache usage', style: TextStyle(color: fs.ash)), + const Spacer(), + Text( + '${_fmtBytes(_usageBytes)} / ' + '${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}', + style: TextStyle(color: fs.parchment), + ), + ]), + const SizedBox(height: 16), + _capSelector(s, fs), + const SizedBox(height: 8), + _prefetchSelector(s, fs), + const SizedBox(height: 8), + SwitchListTile( + key: const Key('cache_liked_toggle'), + contentPadding: EdgeInsets.zero, + title: Text('Cache liked tracks', + style: TextStyle(color: fs.parchment)), + value: s.cacheLikedTracks, + onChanged: (v) => ref + .read(cacheSettingsProvider.notifier) + .setCacheLikedTracks(v), + ), + const SizedBox(height: 12), + Wrap(spacing: 8, runSpacing: 8, children: [ + OutlinedButton( + key: const Key('clear_cache_button'), + onPressed: _confirmClear, + child: const Text('Clear cache'), + ), + OutlinedButton.icon( + key: const Key('sync_now_button'), + onPressed: _syncing + ? null + : () async { + setState(() => _syncing = true); + try { + await ref + .read(syncControllerProvider.notifier) + .sync(); + } finally { + if (mounted) setState(() => _syncing = false); + } + }, + icon: _syncing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync, size: 16), + label: const Text('Sync now'), + ), + ]), + ], + ), + ), + ), + ), + ); + } + + Widget _capSelector(CacheSettings s, FabledSwordTheme fs) { + const options = [ + (1024 * 1024 * 1024, '1 GB'), + (5 * 1024 * 1024 * 1024, '5 GB'), + (10 * 1024 * 1024 * 1024, '10 GB'), + (25 * 1024 * 1024 * 1024, '25 GB'), + (0, 'Unlimited'), + ]; + return Row(children: [ + Expanded(child: Text('Cache size limit', style: TextStyle(color: fs.ash))), + DropdownButton( + key: const Key('cap_selector'), + value: options.any((o) => o.$1 == s.capBytes) + ? s.capBytes + : 5 * 1024 * 1024 * 1024, + items: options + .map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2))) + .toList(), + onChanged: (v) async { + if (v == null) return; + await ref.read(cacheSettingsProvider.notifier).setCapBytes(v); + if (v > 0) { + await ref.read(audioCacheManagerProvider).evict(targetBytes: v); + } + await _refreshUsage(); + }, + ), + ]); + } + + Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) { + const options = [1, 3, 5, 7, 10]; + return Row(children: [ + Expanded(child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))), + DropdownButton( + key: const Key('prefetch_selector'), + value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5, + items: options + .map((n) => DropdownMenuItem(value: n, child: Text('$n tracks'))) + .toList(), + onChanged: (v) async { + if (v == null) return; + await ref.read(cacheSettingsProvider.notifier).setPrefetchWindow(v); + }, + ), + ]); + } + + Future _confirmClear() async { + final fs = Theme.of(context).extension()!; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Clear cache?'), + content: const Text( + 'This deletes all cached audio files (including manually downloaded ones). ' + 'The next play of any track will re-download from the server.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: fs.error), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (confirmed != true) return; + await ref.read(audioCacheManagerProvider).clearAll(); + await _refreshUsage(); + } +} diff --git a/flutter_client/test/settings/storage_section_test.dart b/flutter_client/test/settings/storage_section_test.dart new file mode 100644 index 00000000..b8591d9a --- /dev/null +++ b/flutter_client/test/settings/storage_section_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:minstrel/auth/auth_provider.dart'; +import 'package:minstrel/settings/storage_section.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +class _MockStorage extends Mock implements FlutterSecureStorage {} + +void main() { + setUpAll(() { + registerFallbackValue(''); + }); + + testWidgets('renders Storage heading + all controls', (t) async { + final storage = _MockStorage(); + when(() => storage.read(key: any(named: 'key'))) + .thenAnswer((_) async => null); + when(() => storage.write( + key: any(named: 'key'), value: any(named: 'value'))) + .thenAnswer((_) async {}); + + await t.pumpWidget(ProviderScope( + overrides: [secureStorageProvider.overrideWithValue(storage)], + child: MaterialApp( + theme: buildDarkTheme(), + home: const Scaffold(body: StorageSection()), + ), + )); + await t.pumpAndSettle(); + + expect(find.text('Storage'), findsOneWidget); + expect(find.text('Cache size limit'), findsOneWidget); + expect(find.text('Pre-fetch ahead'), findsOneWidget); + expect(find.byKey(const Key('clear_cache_button')), findsOneWidget); + expect(find.byKey(const Key('sync_now_button')), findsOneWidget); + expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget); + expect(find.byKey(const Key('cap_selector')), findsOneWidget); + expect(find.byKey(const Key('prefetch_selector')), findsOneWidget); + }); + + testWidgets('Clear cache button shows confirm dialog', (t) async { + final storage = _MockStorage(); + when(() => storage.read(key: any(named: 'key'))) + .thenAnswer((_) async => null); + when(() => storage.write( + key: any(named: 'key'), value: any(named: 'value'))) + .thenAnswer((_) async {}); + + await t.pumpWidget(ProviderScope( + overrides: [secureStorageProvider.overrideWithValue(storage)], + child: MaterialApp( + theme: buildDarkTheme(), + home: const Scaffold(body: StorageSection()), + ), + )); + await t.pumpAndSettle(); + + await t.tap(find.byKey(const Key('clear_cache_button'))); + await t.pumpAndSettle(); + + expect(find.text('Clear cache?'), findsOneWidget); + // Cancel returns to the original state. + await t.tap(find.text('Cancel')); + await t.pumpAndSettle(); + expect(find.text('Clear cache?'), findsNothing); + }); +}