-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 4715ac5
abduznik edited this page May 23, 2026
·
1 revision
Commit: 4715ac5843f433418028b16ddda91b80983420d5
Author: abduznik
Date: 2026年05月01日
Why: Adds a new feature or capability to the application.
lib/providers/library_provider.dart | 162 ++++++++++++----------------
lib/providers/paginated_games_provider.dart | 25 ++++-
lib/providers/romm_provider.dart | 14 ++-
lib/ui/screens/game_detail_screen.dart | 6 ++
lib/ui/screens/onboarding_screen.dart | 104 ++++++++++++++----
lib/ui/screens/settings_screen.dart | 87 ++++++++++++++-
6 files changed, 276 insertions(+), 122 deletions(-)
lib/providers/library_provider.dartlib/providers/paginated_games_provider.dartlib/providers/romm_provider.dartlib/ui/screens/game_detail_screen.dartlib/ui/screens/onboarding_screen.dartlib/ui/screens/settings_screen.dart
diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart index d5451fc..36cad4b 100644 --- a/lib/providers/library_provider.dart +++ b/lib/providers/library_provider.dart @@ -1,62 +1,12 @@ import 'dart:typed_data'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import '../core/romm/romm_models.dart'; import 'romm_provider.dart'; import 'shared_prefs_provider.dart'; -const String _gamesCacheKey = 'cached_games'; -const String _platformsCacheKey = 'cached_platforms_v2'; -const String _gamesCacheTimeKey = 'cached_games_time'; -const String _platformsCacheTimeKey = 'cached_platforms_time'; -const int _cacheExpiryDays = 7; - -Future<bool> _isCacheValid(SharedPreferences prefs, String timeKey) async { - final savedTime = prefs.getString(timeKey); - if (savedTime == null) return false; - final cacheTime = DateTime.tryParse(savedTime); - if (cacheTime == null) return false; - return DateTime.now().difference(cacheTime).inDays < _cacheExpiryDays; -} - -Future<List<Game>?> _loadGamesCache(Ref ref) async { - final prefs = ref.read(sharedPreferencesProvider); - final sizeExceeded = prefs.getBool('cache_size_exceeded') ?? false; - if (sizeExceeded) return null; - - final isValid = await _isCacheValid(prefs, _gamesCacheTimeKey); - if (!isValid) return null; - - final jsonString = prefs.getString(_gamesCacheKey); - if (jsonString == null) return null; - - try { - final jsonList = jsonDecode(jsonString) as List<dynamic>; - return jsonList - .map((item) => Game.fromJson(item as Map<String, dynamic>)) - .toList(); - } catch (e) { - return null; - } -} - -Future<List<Platform>?> _loadPlatformsCache(Ref ref) async { - final prefs = ref.read(sharedPreferencesProvider); - final isValid = await _isCacheValid(prefs, _platformsCacheTimeKey); - if (!isValid) return null; - final jsonString = prefs.getString(_platformsCacheKey); - if (jsonString == null) return null; - try { - final jsonList = jsonDecode(jsonString) as List<dynamic>; - return jsonList - .map((item) => Platform.fromJson(item as Map<String, dynamic>)) - .toList(); - } catch (e) { - return null; - } -} +// Old cache functions removed in favor of LibrarySnapshotService and MetadataCacheService const Map<String, Map<String, dynamic>> kDisplayPresets = { 'windows_best': { @@ -104,83 +54,111 @@ final activePresetLoaderProvider = FutureProvider<void>((ref) async {}); final platformsProvider = FutureProvider<List<Platform>>((ref) async { final service = ref.watch(rommServiceProvider); + final snapshotService = ref.watch(librarySnapshotServiceProvider); ref.watch(isOfflineProvider); - if (service == null) return []; + + final snapshot = await snapshotService.loadPlatforms(); + + if (service == null) { + return snapshot.where((p) => p.gamesCount > 0).toList(); + } - final cached = await _loadPlatformsCache(ref); - if (cached != null) { + if (snapshot.isNotEmpty) { + // Background refresh Future.microtask(() async { try { final fresh = await service.getPlatforms(); if (fresh.isNotEmpty) { - final prefs = ref.read(sharedPreferencesProvider); - final jsonList = fresh.map((p) => { - 'id': p.id, - 'name': p.name, - 'slug': p.slug, - 'games_count': p.gamesCount, - }).toList(); - await prefs.setString(_platformsCacheKey, jsonEncode(jsonList)); - await prefs.setString(_platformsCacheTimeKey, DateTime.now().toIso8601String()); + await snapshotService.savePlatforms(fresh); } } catch (_) {} }); - return cached.where((p) => p.gamesCount > 0).toList(); + return snapshot.where((p) => p.gamesCount > 0).toList(); } final platforms = await service.getPlatforms(); if (platforms.isNotEmpty) { - final prefs = ref.read(sharedPreferencesProvider); - final jsonList = platforms.map((p) => { - 'id': p.id, - 'name': p.name, - 'slug': p.slug, - 'games_count': p.gamesCount, - }).toList(); - await prefs.setString(_platformsCacheKey, jsonEncode(jsonList)); - await prefs.setString(_platformsCacheTimeKey, DateTime.now().toIso8601String()); + await snapshotService.savePlatforms(platforms); } return platforms.where((p) => p.gamesCount > 0).toList(); }); +final collectionsProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { + final service = ref.watch(rommServiceProvider); + final snapshotService = ref.watch(librarySnapshotServiceProvider); + ref.watch(isOfflineProvider); + + final snapshot = await snapshotService.loadCollections(); + + if (service == null) return snapshot; + + if (snapshot.isNotEmpty) { + // Background refresh + Future.microtask(() async { + try { + final fresh = await service.getCollections(); + if (fresh.isNotEmpty) { + await snapshotService.saveCollections(fresh); + } + } catch (_) {} + }); + return snapshot; + } + + final collections = await service.getCollections(); + if (collections.isNotEmpty) { + await snapshotService.saveCollections(collections); + } + return collections; +}); + final allGamesProvider = FutureProvider<List<Game>>((ref) async { final service = ref.watch(rommServiceProvider); + final cacheService = await ref.watch(metadataCacheServiceProvider.future); ref.watch(isOfflineProvider); + final selectedPlatformId = ref.watch(selectedPlatformIdProvider); - if (service == null) return []; - final platformIdStr = selectedPlatformId?.toString(); + if (service == null) { + return cacheService.getOfflineGames(platformId: platformIdStr); + } + if (platformIdStr != null) { - try { - return await service.getAllGames(platformId: platformIdStr); - } catch (e) { - return await service.getAllGames(); + // Check if platform cache is valid + final platforms = await ref.watch(platformsProvider.future); + final platform = platforms.firstWhere((p) => p.id.toString() == platformIdStr, orElse: () => Platform(id: 0, name: '', slug: '')); + + if (platform.id != 0 && cacheService.isPlatformValid(platformIdStr, platform.gamesCount)) { + final cached = cacheService.getOfflineGames(platformId: platformIdStr); + if (cached.isNotEmpty) return cached; + } + + final games = await service.getAllGames(platformId: platformIdStr); + if (games.isNotEmpty) { + await cacheService.saveGames(games); + await cacheService.updatePlatformCount(platformIdStr, platform.gamesCount); } + return games; } - final cached = await _loadGamesCache(ref); - if (cached != null) { + // General all-games view + final cached = cacheService.cachedGames; + if (cached.isNotEmpty) { Future.microtask(() async { try { - final fresh = await service.getAllGames(platformId: platformIdStr); + final fresh = await service.getAllGames(); if (fresh.isNotEmpty) { - final prefs = ref.read(sharedPreferencesProvider); - final jsonList = fresh.map((g) => g.toJson()).toList(); - await prefs.setString(_gamesCacheKey, jsonEncode(jsonList)); - await prefs.setString(_gamesCacheTimeKey, DateTime.now().toIso8601String()); + await cacheService.saveGames(fresh); } } catch (_) {} }); return cached; } - final games = await service.getAllGames(platformId: platformIdStr); + final games = await service.getAllGames(); if (games.isNotEmpty) { - final prefs = ref.read(sharedPreferencesProvider); - final jsonList = games.map((g) => g.toJson()).toList(); - await prefs.setString(_gamesCacheKey, jsonEncode(jsonList)); - await prefs.setString(_gamesCacheTimeKey, DateTime.now().toIso8601String()); + await cacheService.saveGames(games); } return games; }); diff --git a/lib/providers/paginated_games_provider.dart b/lib/providers/paginated_games_provider.dart index b33a1da..456b4af 100644 --- a/lib/providers/paginated_games_provider.dart +++ b/lib/providers/paginated_games_provider.dart @@ -122,7 +122,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> { _currentSearch = search; final key = _key(platformId, search); - // Serve from cache immediately if available + // Serve from memory cache immediately if available if (_cache.containsKey(key)) { state = PaginatedGamesState( games: _cache[key]!, @@ -135,6 +135,29 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> { return; } + // Serve from persistent cache if available + final cacheService = _ref.read(metadataCacheServiceProvider).value; + if (cacheService != null) { + final offline = cacheService.getOfflineGames( + platformId: platformId, + search: search, + genres: _activeFilters.genres, + regions: _activeFilters.regions, + languages: _activeFilters.languages, + ); + + if (offline.isNotEmpty) { + state = PaginatedGamesState( + games: offline, + total: offline.length, + hasMore: true, + isLoading: false, + ); + _backgroundRefresh(platformId: platformId, search: search, key: key); + return; + } + } + // No cache — show loading and fetch state = const PaginatedGamesState(isLoading: true); final service = _ref.read(rommServiceProvider); diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart index 8636875..727698e 100644 --- a/lib/providers/romm_provider.dart +++ b/lib/providers/romm_provider.dart @@ -9,10 +9,11 @@ import 'package:freegosy/core/save/backup_repository.dart'; import 'package:freegosy/core/save/backup_service.dart'; import 'package:freegosy/core/emulator/strategies/windows_strategy.dart'; import 'package:freegosy/core/emulator/emulator_registry_data.dart'; -import 'package:freegosy/core/storage/download_cache_service.dart'; +import 'package:freegosy/core/romm/rom_scanner_service.dart'; +import 'package:freegosy/core/romm/library_snapshot_service.dart'; import 'package:freegosy/core/storage/metadata_cache_service.dart'; import 'package:freegosy/core/storage/rom_mapping_service.dart'; -import 'package:freegosy/core/romm/rom_scanner_service.dart'; +import 'package:freegosy/core/storage/download_cache_service.dart'; import 'package:freegosy/providers/custom_emulators_provider.dart'; import 'package:freegosy/providers/shared_prefs_provider.dart'; import 'package:freegosy/core/emulator/firmware_service.dart'; @@ -176,12 +177,15 @@ final rommServiceProvider = Provider<RommService?>((ref) { }); final metadataCacheServiceProvider = FutureProvider<MetadataCacheService>((ref) async { - final prefs = ref.watch(sharedPreferencesProvider); - final service = MetadataCacheService(prefs); - service.load(); + final service = MetadataCacheService(); + await service.load(); return service; }); +final librarySnapshotServiceProvider = Provider<LibrarySnapshotService>((ref) { + return LibrarySnapshotService(); +}); + final romMappingServiceProvider = FutureProvider<RomMappingService>((ref) async { final service = RomMappingService(); await service.init(); diff --git a/lib/ui/screens/game_detail_screen.dart b/lib/ui/screens/game_detail_screen.dart index 77ee52b..0b48c2f 100644 --- a/lib/ui/screens/game_detail_screen.dart +++ b/lib/ui/screens/game_detail_screen.dart @@ -103,6 +103,12 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> { _syncStateWithGame(updated); }); _checkDownloadStatus(); + + // Update persistent cache so the list view and offline mode have the latest details + final cacheService = ref.read(metadataCacheServiceProvider).value; + if (cacheService != null) { + await cacheService.saveGames([updated]); + } } } catch (_) {} } diff --git a/lib/ui/screens/onboarding_screen.dart b/lib/ui/screens/onboarding_screen.dart index 783158b..cf1d716 100644 --- a/lib/ui/screens/onboarding_screen.dart +++ b/lib/ui/screens/onboarding_screen.dart @@ -26,6 +26,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> { // Step 1: Server Config final _baseUrlController = TextEditingController(); final _apiKeyController = TextEditingController(); + final _pairingCodeController = TextEditingController(); + bool _usePairingCode = false; bool _isTesting = false; String? _testError; bool _testSuccess = false; @@ -97,9 +99,24 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> { }); try { + String apiKey = _apiKeyController.text.trim(); + String? authToken; + + if (_usePairingCode) { + final code = _pairingCodeController.text.trim().replaceAll(RegExp(r'[^a-zA-Z0-9]'), ''); + if (code.isEmpty) { + setState(() => _testError = 'Please enter a pairing code'); + return; + } + final token = await RommService.exchangePairingCode(url, code); + _apiKeyController.text = token; + apiKey = token; + } + final testConfig = RomMConfig( baseUrl: url, - apiKey: _apiKeyController.text.trim(), + apiKey: apiKey, + token: authToken, username: '', password: '', ); @@ -298,26 +315,75 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> { ), ), ), - const SizedBox(height: 20), - TextField( - controller: _apiKeyController, - decoration: InputDecoration( - labelText: 'API Key', - hintText: 'Found in RomM User Settings', - prefixIcon: const Icon(Icons.key), - suffixIcon: IconButton( - icon: const Icon(Icons.paste), - onPressed: () async { - final data = await Clipboard.getData(Clipboard.kTextPlain); - if (data != null && data.text != null) { - _apiKeyController.text = data.text!; - } - }, - tooltip: 'Paste from clipboard', + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () => setState(() => _usePairingCode = false), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: !_usePairingCode ? Colors.deepPurple : Colors.transparent, width: 2)), + ), + child: Text('API KEY', textAlign: TextAlign.center, style: TextStyle(color: !_usePairingCode ? Colors.white : Colors.grey, fontWeight: FontWeight.bold)), + ), + ), ), - ), - obscureText: true, + Expanded( + child: InkWell( + onTap: () => setState(() => _usePairingCode = true), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: _usePairingCode ? Colors.deepPurple : Colors.transparent, width: 2)), + ), + child: Text('PAIRING CODE', textAlign: TextAlign.center, style: TextStyle(color: _usePairingCode ? Colors.white : Colors.grey, fontWeight: FontWeight.bold)), + ), + ), + ), + ], ), + const SizedBox(height: 24), + if (!_usePairingCode) + TextField( + controller: _apiKeyController, + decoration: InputDecoration( + labelText: 'API Key', + hintText: 'Found in RomM User Settings', + prefixIcon: const Icon(Icons.key), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data != null && data.text != null) { + _apiKeyController.text = data.text!; + } + }, + tooltip: 'Paste from clipboard', + ), + ), + obscureText: true, + ) + else + TextField( + controller: _pairingCodeController, + decoration: InputDecoration( + labelText: '8-Digit Pairing Code', + hintText: 'Generated in RomM Web UI', + prefixIcon: const Icon(Icons.phonelink_setup), + suffixIcon: IconButton( + icon: const Icon(Icons.paste), + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data != null && data.text != null) { + _pairingCodeController.text = data.text!; + } + }, + tooltip: 'Paste from clipboard', + ), + ), + ), const SizedBox(height: 32), if (_testError != null) Container( diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index e5314cb..7e6d046 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -33,7 +33,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { bool _isLegacyAuth = false; bool _isTestingConnection = false; String? _connectionError; - bool _connectionSuccess = false; + String? _pairedToken; @override void initState() { @@ -159,11 +159,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { padding: const EdgeInsets.only(bottom: 12), child: Text(_connectionError!, style: const TextStyle(color: Colors.red, fontSize: 13)), ), - if (_connectionSuccess) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text('Connection successful!', style: TextStyle(color: Colors.green, fontSize: 13)), ), + Row( + children: [ + ElevatedButton.icon( + onPressed: () => _showPairingDialog(context), + icon: const Icon(Icons.phonelink_setup), + label: const Text('Pair New Device'), + ), + const Spacer(), + ], + ), + const SizedBox(height: 12), Row( children: [ ElevatedButton( @@ -177,7 +187,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { setState(() { _isTestingConnection = true; _connectionError = null; - _connectionSuccess = false; }); try { @@ -186,7 +195,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { baseUrl: baseUrl, username: _usernameController.text.trim(), password: _passwordController.text, - apiKey: _apiKeyController.text.trim(), + apiKey: _pairedToken == null ? _apiKeyController.text.trim() : '', + token: _pairedToken, ); final testService = RommService(testConfig); @@ -195,7 +205,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { if (mounted) { setState(() { _isTestingConnection = false; - _connectionSuccess = true; }); } } catch (e) { @@ -223,9 +232,20 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { if (_isLegacyAuth) { await prefs.setString('rommUsername', _usernameController.text.trim()); await SecureStorageService.write('rommPassword', _passwordController.text, prefs); + await SecureStorageService.delete('rommApiKey', prefs); + await SecureStorageService.delete('rommAuthToken', prefs); + } else if (_pairedToken != null) { + await SecureStorageService.write('rommAuthToken', _pairedToken!, prefs); + await SecureStorageService.delete('rommApiKey', prefs); + await prefs.setString('rommUsername', ''); + await SecureStorageService.delete('rommPassword', prefs); } else { await SecureStorageService.write('rommApiKey', _apiKeyController.text.trim(), prefs); + await SecureStorageService.delete('rommAuthToken', prefs); + await prefs.setString('rommUsername', ''); + await SecureStorageService.delete('rommPassword', prefs); } + _pairedToken = null; ref.invalidate(rommConfigProvider); ref.invalidate(rommServiceProvider); if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Settings saved.'))); @@ -397,6 +417,63 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { Widget _buildRetroArchSettingsSection(BuildContext context, WidgetRef ref) => const SizedBox(); Widget _buildLinuxSettingsSection(BuildContext context, WidgetRef ref, DirectoryService directoryService) => const SizedBox(); Widget _buildLegalSection(BuildContext context) => const SizedBox(); + + void _showPairingDialog(BuildContext context) { + final codeController = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Pair with Web UI'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Enter the 8-digit code generated in your RomM Web UI settings.'), + const SizedBox(height: 16), + TextField( + controller: codeController, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Pairing Code', + hintText: 'XXXXXXXX', + border: OutlineInputBorder(), + ), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24, letterSpacing: 4, fontWeight: FontWeight.bold, fontFamily: 'monospace'), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + ElevatedButton( + onPressed: () async { + final code = codeController.text.trim().replaceAll(RegExp(r'[^a-zA-Z0-9]'), ''); + if (code.length < 8) return; + + try { + final url = _baseUrlController.text.trim(); + if (url.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter a Server URL first.'))); + return; + } + final token = await RommService.exchangePairingCode(url, code); + _apiKeyController.text = token; + setState(() => _isLegacyAuth = false); + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Successfully paired! Click Save to apply.'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Pairing failed: ${e.toString().split('\n').first}'))); + } + } + }, + child: const Text('Pair'), + ), + ], + ), + ); + } } class _LogsDialogContent extends StatefulWidget {