Skip to content

commit fe485a5

abduznik edited this page May 23, 2026 · 1 revision

Implement Offline Mode with metadata caching

Commit: fe485a5db1b728c49b6579957263033068140d6d

Author: Yan

Date: 2026-04-13

Why: Major implementation or integration of a feature set.

Files Changed

lib/core/romm/romm_models.dart               | 25 +++++++++
 lib/core/storage/metadata_cache_service.dart | 84 ++++++++++++++++++++++++++++
 lib/providers/paginated_games_provider.dart  | 51 ++++++++++++++++-
 lib/providers/romm_provider.dart             |  7 +++
 lib/ui/screens/library_screen.dart           | 66 ++++++++++++++++++++--
 5 files changed, 226 insertions(+), 7 deletions(-)
  • lib/core/romm/romm_models.dart
  • lib/core/storage/metadata_cache_service.dart
  • lib/providers/paginated_games_provider.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/library_screen.dart

Diff

diff --git a/lib/core/romm/romm_models.dart b/lib/core/romm/romm_models.dart
index e059537..5190458 100644
--- a/lib/core/romm/romm_models.dart
+++ b/lib/core/romm/romm_models.dart
@@ -148,6 +148,31 @@ class Game {
       'file_size_bytes': fileSize,
       'multi_file_path': multiFilePath,
       'has_multiple_files': hasMultipleFiles,
+      'files': files,
+      'summary': summary,
+      'metadatum': {
+        'genres': genres,
+        'companies': companies,
+        'player_count': playerCount,
+        'first_release_date': firstReleaseDate,
+        'average_rating': averageRating,
+      },
+      'regions': regions,
+      'languages': languages,
+      'tags': tags,
+      'merged_screenshots': mergedScreenshots,
+      'ss_metadata': {
+        'screenshot_url': screenshotUrl,
+        'fanart_url': fanartUrl,
+      },
+      'rom_user': {
+        'last_played': lastPlayed?.toIso8601String(),
+        'rating': userRating,
+        'completion': completion,
+        'status': status,
+        'backlogged': backlogged,
+        'now_playing': nowPlaying,
+      },
     };
   }
 }
diff --git a/lib/core/storage/metadata_cache_service.dart b/lib/core/storage/metadata_cache_service.dart
new file mode 100644
index 0000000..8296488
--- /dev/null
+++ b/lib/core/storage/metadata_cache_service.dart
@@ -0,0 +1,84 @@
+import 'dart:convert';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../romm/romm_models.dart';
+
+class MetadataCacheService {
+  static const String _gamesKey = 'cached_games_metadata';
+  List<Game> _cachedGames = [];
+
+  MetadataCacheService();
+
+  List<Game> get cachedGames => _cachedGames;
+
+  Future<void> load() async {
+    try {
+      final prefs = await SharedPreferences.getInstance();
+      final jsonStr = prefs.getString(_gamesKey);
+      if (jsonStr == null) return;
+      
+      final List<dynamic> decoded = jsonDecode(jsonStr);
+      _cachedGames = decoded.map((json) => Game.fromJson(json)).toList();
+    } catch (_) {
+      _cachedGames = [];
+    }
+  }
+
+  Future<void> saveGames(List<Game> games) async {
+    // Merge new games with existing cache, avoiding duplicates by ID
+    final Map<String, Game> gameMap = {for (var g in _cachedGames) g.id: g};
+    for (var g in games) {
+      gameMap[g.id] = g;
+    }
+    _cachedGames = gameMap.values.toList();
+    await _persist();
+  }
+
+  Future<void> _persist() async {
+    try {
+      final prefs = await SharedPreferences.getInstance();
+      final jsonList = _cachedGames.map((g) => g.toJson()).toList();
+      await prefs.setString(_gamesKey, jsonEncode(jsonList));
+    } catch (_) {}
+  }
+
+  List<Game> getOfflineGames({
+    String? platformId,
+    String? search,
+    List<String>? genres,
+    List<String>? regions,
+    List<String>? languages,
+  }) {
+    return _cachedGames.where((game) {
+      // Platform filter
+      if (platformId != null && game.platformId.toString() != platformId) {
+        return false;
+      }
+
+      // Search filter
+      if (search != null && search.isNotEmpty) {
+        final query = search.toLowerCase();
+        if (!game.name.toLowerCase().contains(query) &&
+            !(game.platformDisplayName?.toLowerCase().contains(query) ?? false)) {
+          return false;
+        }
+      }
+
+      // Genre filter
+      if (genres != null && genres.isNotEmpty) {
+        if (!genres.any((g) => game.genres.contains(g))) return false;
+      }
+
+      // Region filter
+      if (regions != null && regions.isNotEmpty) {
+        if (!regions.any((r) => game.regions.contains(r))) return false;
+      }
+
+      // Language filter
+      if (languages != null && languages.isNotEmpty) {
+        if (!languages.any((l) => game.languages.contains(l))) return false;
+      }
+
+      return true;
+    }).toList();
+  }
+}
diff --git a/lib/providers/paginated_games_provider.dart b/lib/providers/paginated_games_provider.dart
index 0d1f03f..c571b3b 100644
--- a/lib/providers/paginated_games_provider.dart
+++ b/lib/providers/paginated_games_provider.dart
@@ -137,10 +137,13 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
     // No cache — show loading and fetch
     state = const PaginatedGamesState(isLoading: true);
     final service = _ref.read(rommServiceProvider);
+    
     if (service == null) {
-      state = state.copyWith(isLoading: false, error: 'Not connected');
+      // Fallback to offline cache
+      await _loadOffline(platformId, search);
       return;
     }
+
     try {
       final result = await service.getGamesPage(
         offset: 0,
@@ -153,6 +156,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
         collections: _activeFilters.collections,
         statuses: _activeFilters.statuses,
       );
+      
       _cache[key] = result.games;
       _offsets[key] = result.games.length;
       _totals[key] = result.total;
@@ -162,11 +166,42 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
         hasMore: result.games.length < result.total,
         isLoading: false,
       );
+
+      // Persist results for offline use
+      final cacheService = _ref.read(metadataCacheServiceProvider).value;
+      if (cacheService != null) {
+        await cacheService.saveGames(result.games);
+      }
     } catch (e) {
-      state = state.copyWith(isLoading: false, error: e.toString());
+      // On error, also try fallback
+      await _loadOffline(platformId, search, error: e.toString());
     }
   }
 
+  Future<void> _loadOffline(String? platformId, String? search, {String? error}) async {
+    final cacheService = _ref.read(metadataCacheServiceProvider).value;
+    if (cacheService == null) {
+      state = state.copyWith(isLoading: false, error: error ?? 'Not connected');
+      return;
+    }
+
+    final offlineGames = cacheService.getOfflineGames(
+      platformId: platformId,
+      search: search,
+      genres: _activeFilters.genres,
+      regions: _activeFilters.regions,
+      languages: _activeFilters.languages,
+    );
+
+    state = PaginatedGamesState(
+      games: offlineGames,
+      total: offlineGames.length,
+      hasMore: false,
+      isLoading: false,
+      error: error != null ? 'Offline Mode (Server: $error)' : 'Offline Mode',
+    );
+  }
+
   Future<void> _backgroundRefresh({
     required String? platformId,
     required String? search,
@@ -197,6 +232,12 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
           hasMore: result.games.length < result.total,
           isLoading: false,
         );
+        
+        // Update cache
+        final cacheService = _ref.read(metadataCacheServiceProvider).value;
+        if (cacheService != null) {
+          await cacheService.saveGames(result.games);
+        }
       }
     } catch (_) {}
   }
@@ -230,6 +271,12 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
         hasMore: merged.length < result.total,
         isLoadingMore: false,
       );
+
+      // Update cache
+      final cacheService = _ref.read(metadataCacheServiceProvider).value;
+      if (cacheService != null) {
+        await cacheService.saveGames(result.games);
+      }
     } catch (e) {
       state = state.copyWith(isLoadingMore: false, error: e.toString());
     }
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index f7eba4d..a7bd07f 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -10,6 +10,7 @@ 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/storage/metadata_cache_service.dart';
 
 import 'package:freegosy/core/emulator/firmware_service.dart';
 
@@ -139,3 +140,9 @@ final rommServiceProvider = Provider<RommService?>((ref) {
   }
   return null;
 });
+
+final metadataCacheServiceProvider = FutureProvider<MetadataCacheService>((ref) async {
+  final service = MetadataCacheService();
+  await service.load();
+  return service;
+});
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index 9032605..cbec958 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -446,15 +446,71 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
                     ],
                   ),
                 ),
+              if (paginatedState.error != null && paginatedState.error!.contains('Offline Mode'))
+                Container(
+                  color: Colors.orange.withValues(alpha: 0.1),
+                  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+                  child: Row(
+                    children: [
+                      const Icon(Icons.cloud_off, color: Colors.orange),
+                      const SizedBox(width: 12),
+                      Expanded(
+                        child: Column(
+                          crossAxisAlignment: CrossAxisAlignment.start,
+                          children: [
+                            const Text(
+                              'Offline Mode',
+                              style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
+                            ),
+                            Text(
+                              paginatedState.error!,
+                              style: const TextStyle(color: Colors.white70, fontSize: 12),
+                            ),
+                          ],
+                        ),
+                      ),
+                      TextButton(
+                        onPressed: () {
+                          ref.invalidate(rommServiceProvider);
+                          ref.read(paginatedGamesProvider.notifier).loadInitial(
+                            platformId: ref.read(selectedPlatformIdProvider)?.toString(),
+                            search: ref.read(searchQueryProvider),
+                          );
+                        },
+                        child: const Text('Retry'),
+                      ),
+                    ],
+                  ),
+                ),
               Expanded(
                 child: paginatedState.isLoading
                     ? buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing, context)
-                    : paginatedState.error != null
+                    : (paginatedState.error != null && !paginatedState.error!.contains('Offline Mode'))
                         ? Center(
-                            child: Padding(
-                            padding: const EdgeInsets.all(16),
-                            child: Text('Error: ${paginatedState.error}', style: const TextStyle(color: Colors.red)),
-                          ))
+                            child: Column(
+                              mainAxisAlignment: MainAxisAlignment.center,
+                              children: [
+                                const Icon(Icons.error_outline, color: Colors.red, size: 48),
+                                const SizedBox(height: 16),
+                                Text(
+                                  'Error: ${paginatedState.error}',
+                                  style: const TextStyle(color: Colors.red),
+                                  textAlign: TextAlign.center,
+                                ),
+                                const SizedBox(height: 16),
+                                ElevatedButton(
+                                  onPressed: () {
+                                    ref.invalidate(rommServiceProvider);
+                                    ref.read(paginatedGamesProvider.notifier).loadInitial(
+                                      platformId: ref.read(selectedPlatformIdProvider)?.toString(),
+                                      search: ref.read(searchQueryProvider),
+                                    );
+                                  },
+                                  child: const Text('Retry'),
+                                ),
+                              ],
+                            ),
+                          )
                         : Column(
                             children: [
                               Padding(

Clone this wiki locally