-
-
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 {