import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.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 '../likes/likes_provider.dart' show likedIdsProvider; import '../theme/theme_extension.dart'; /// Settings card: per-bucket usage, two cap selectors (Liked + /// Recently-played), prefetch window, cache-liked toggle, Clear /// cache + Sync now. #427 S2/S3: two independent budgets. class StorageSection extends ConsumerStatefulWidget { const StorageSection({super.key}); @override ConsumerState createState() => _StorageSectionState(); } class _StorageSectionState extends ConsumerState { BucketUsage? _usage; bool _syncing = false; @override void initState() { super.initState(); _refreshUsage(); } Set _likedSet() => ref.read(likedIdsProvider).value?.tracks ?? const {}; Future _refreshUsage() async { final mgr = ref.read(audioCacheManagerProvider); final u = await mgr.bucketUsage(_likedSet()); if (mounted) setState(() => _usage = u); } 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'; } String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes); @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), _usageRow('Liked', _usage?.liked, s.likedCapBytes, fs), const SizedBox(height: 4), _usageRow('Recently played', _usage?.rolling, s.rollingCapBytes, fs), const SizedBox(height: 16), _capSelector( 'Liked cache limit', const Key('liked_cap_selector'), s.likedCapBytes, (v) => ref .read(cacheSettingsProvider.notifier) .setLikedCapBytes(v), s, fs, ), const SizedBox(height: 8), _capSelector( 'Recently-played cache limit', const Key('rolling_cap_selector'), s.rollingCapBytes, (v) => ref .read(cacheSettingsProvider.notifier) .setRollingCapBytes(v), 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(LucideIcons.refresh_cw, size: 16), label: const Text('Sync now'), ), ]), ], ), ), ), ), ); } Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) { return Row(children: [ Text(label, style: TextStyle(color: fs.ash)), const Spacer(), Text('${_fmtBytes(used)} / ${_cap(cap)}', style: TextStyle(color: fs.parchment)), ]); } static const _capOptions = [ (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'), ]; Widget _capSelector( String label, Key key, int current, Future Function(int) setCap, CacheSettings s, FabledSwordTheme fs, ) { return Row(children: [ Expanded(child: Text(label, style: TextStyle(color: fs.ash))), DropdownButton( key: key, value: _capOptions.any((o) => o.$1 == current) ? current : 5 * 1024 * 1024 * 1024, items: _capOptions .map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2))) .toList(), onChanged: (v) async { if (v == null) return; await setCap(v); // Enforce immediately against the freshest settings. final fresh = ref.read(cacheSettingsProvider).value; if (fresh != null) { await ref.read(audioCacheManagerProvider).evictBuckets( likedCap: fresh.likedCapBytes, rollingCap: fresh.rollingCapBytes, liked: _likedSet(), ); } 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 (liked and recently-played). ' 'The next play of any track re-downloads 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(); } }