From 2bcbb53c4933da155cde613a3b6e62e3057b76b3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 23:15:17 -0400 Subject: [PATCH] feat(flutter/cache): connectivityProvider + cacheSettingsProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectivityProvider — StreamProvider; true when any connectivity result is non-none. No Wi-Fi gate per operator decision. cacheSettingsProvider — AsyncNotifier persisting capBytes / prefetchWindow / cacheLikedTracks via flutter_secure_storage. Defaults: 5 GB cap, 5-track prefetch, cache liked = on. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/cache/cache_settings_provider.dart | 87 +++++++++++++++++++ .../lib/cache/connectivity_provider.dart | 13 +++ .../cache/cache_settings_provider_test.dart | 76 ++++++++++++++++ .../cache/connectivity_provider_test.dart | 14 +++ 4 files changed, 190 insertions(+) create mode 100644 flutter_client/lib/cache/cache_settings_provider.dart create mode 100644 flutter_client/lib/cache/connectivity_provider.dart create mode 100644 flutter_client/test/cache/cache_settings_provider_test.dart create mode 100644 flutter_client/test/cache/connectivity_provider_test.dart diff --git a/flutter_client/lib/cache/cache_settings_provider.dart b/flutter_client/lib/cache/cache_settings_provider.dart new file mode 100644 index 00000000..6a17e609 --- /dev/null +++ b/flutter_client/lib/cache/cache_settings_provider.dart @@ -0,0 +1,87 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../auth/auth_provider.dart' show secureStorageProvider; + +/// Operator-tunable cache settings (#357 plan B). Persisted via +/// flutter_secure_storage on the same device. +class CacheSettings { + const CacheSettings({ + required this.capBytes, + required this.prefetchWindow, + required this.cacheLikedTracks, + }); + + /// 0 = unlimited. + final int capBytes; + + /// 1..10. Number of next-tracks the prefetcher pre-downloads. + final int prefetchWindow; + + /// When true, every like_track event triggers a pin with source autoLiked. + final bool cacheLikedTracks; + + CacheSettings copyWith({ + int? capBytes, + int? prefetchWindow, + bool? cacheLikedTracks, + }) => + CacheSettings( + capBytes: capBytes ?? this.capBytes, + prefetchWindow: prefetchWindow ?? this.prefetchWindow, + cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks, + ); + + static const defaults = CacheSettings( + capBytes: 5 * 1024 * 1024 * 1024, + prefetchWindow: 5, + cacheLikedTracks: true, + ); +} + +class CacheSettingsController extends AsyncNotifier { + static const _kCap = 'cache_cap_bytes'; + static const _kPrefetch = 'cache_prefetch_window'; + static const _kCacheLiked = 'cache_liked_tracks'; + + late FlutterSecureStorage _storage; + + @override + Future build() async { + _storage = ref.read(secureStorageProvider); + final cap = await _storage.read(key: _kCap); + final pre = await _storage.read(key: _kPrefetch); + final liked = await _storage.read(key: _kCacheLiked); + return CacheSettings( + capBytes: cap == null + ? CacheSettings.defaults.capBytes + : int.tryParse(cap) ?? CacheSettings.defaults.capBytes, + prefetchWindow: pre == null + ? CacheSettings.defaults.prefetchWindow + : (int.tryParse(pre) ?? 5).clamp(1, 10), + cacheLikedTracks: liked == null + ? CacheSettings.defaults.cacheLikedTracks + : liked == 'true', + ); + } + + Future setCapBytes(int bytes) async { + await _storage.write(key: _kCap, value: bytes.toString()); + state = AsyncData(state.value!.copyWith(capBytes: bytes)); + } + + Future setPrefetchWindow(int n) async { + final clamped = n.clamp(1, 10); + await _storage.write(key: _kPrefetch, value: clamped.toString()); + state = AsyncData(state.value!.copyWith(prefetchWindow: clamped)); + } + + Future setCacheLikedTracks(bool on) async { + await _storage.write(key: _kCacheLiked, value: on.toString()); + state = AsyncData(state.value!.copyWith(cacheLikedTracks: on)); + } +} + +final cacheSettingsProvider = + AsyncNotifierProvider( + CacheSettingsController.new); diff --git a/flutter_client/lib/cache/connectivity_provider.dart b/flutter_client/lib/cache/connectivity_provider.dart new file mode 100644 index 00000000..d3cf4d71 --- /dev/null +++ b/flutter_client/lib/cache/connectivity_provider.dart @@ -0,0 +1,13 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Online if at least one connectivity result is non-none. +/// connectivity_plus reports the union (wifi, mobile, vpn, etc.) — operator +/// chose "no Wi-Fi gate" for #357, so any connection means "go ahead and +/// pull/cache". +final connectivityProvider = StreamProvider((ref) { + final c = Connectivity(); + return c.onConnectivityChanged.map( + (results) => results.any((r) => r != ConnectivityResult.none), + ); +}); diff --git a/flutter_client/test/cache/cache_settings_provider_test.dart b/flutter_client/test/cache/cache_settings_provider_test.dart new file mode 100644 index 00000000..0df3681d --- /dev/null +++ b/flutter_client/test/cache/cache_settings_provider_test.dart @@ -0,0 +1,76 @@ +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/cache/cache_settings_provider.dart'; + +class _MockStorage extends Mock implements FlutterSecureStorage {} + +void main() { + setUpAll(() { + registerFallbackValue(''); + }); + + test('returns defaults when storage is empty', () 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 {}); + + final container = ProviderContainer(overrides: [ + secureStorageProvider.overrideWithValue(storage), + ]); + addTearDown(container.dispose); + + final s = await container.read(cacheSettingsProvider.future); + expect(s.capBytes, CacheSettings.defaults.capBytes); + expect(s.prefetchWindow, 5); + expect(s.cacheLikedTracks, true); + }); + + test('setPrefetchWindow clamps to 1..10', () 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 {}); + + final container = ProviderContainer(overrides: [ + secureStorageProvider.overrideWithValue(storage), + ]); + addTearDown(container.dispose); + + await container.read(cacheSettingsProvider.future); + final ctrl = container.read(cacheSettingsProvider.notifier); + await ctrl.setPrefetchWindow(99); + expect(container.read(cacheSettingsProvider).value!.prefetchWindow, 10); + await ctrl.setPrefetchWindow(0); + expect(container.read(cacheSettingsProvider).value!.prefetchWindow, 1); + }); + + test('setCacheLikedTracks toggles persisted', () 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 {}); + + final container = ProviderContainer(overrides: [ + secureStorageProvider.overrideWithValue(storage), + ]); + addTearDown(container.dispose); + + await container.read(cacheSettingsProvider.future); + await container.read(cacheSettingsProvider.notifier).setCacheLikedTracks(false); + expect( + container.read(cacheSettingsProvider).value!.cacheLikedTracks, false); + verify(() => storage.write(key: 'cache_liked_tracks', value: 'false')) + .called(1); + }); +} diff --git a/flutter_client/test/cache/connectivity_provider_test.dart b/flutter_client/test/cache/connectivity_provider_test.dart new file mode 100644 index 00000000..15fd4f84 --- /dev/null +++ b/flutter_client/test/cache/connectivity_provider_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/cache/connectivity_provider.dart'; + +void main() { + test('connectivityProvider is reachable from a ProviderContainer', () { + // Smoke test: provider builds without throwing. Real connectivity + // tests would need a connectivity_plus mock; defer to manual ops. + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(connectivityProvider.future), isA>()); + }); +}