-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 1c36b87
abduznik edited this page May 23, 2026
·
1 revision
Commit: 1c36b876fc1d9dadf38a161a035e01b6503acc73
Author: abduznik
Date: 2026-03-28
- fix: universal save sync with hash-based deduplication
- Dolphin save sync now filters GCI files by game name match instead of uploading entire memory card
- Hash comparison before every upload skips unchanged saves using MD5 stored in SharedPreferences
- Works for all file types including fixed-size PS1 PS2 and Xbox memory cards where file size never changes
- Pull sync uses remote updated_at timestamp to skip unnecessary downloads
- feat: paginated game loading with server-side platform filtering
- Add getGamesPage() to RommService using RomM 4.x platform_ids param
- Add PaginatedGamesNotifier with per-key in-memory cache
- Wire pagination into LibraryScreen with scroll-triggered loadMore
- Platform switching triggers server-side filtered page fetch
- Search triggers server-side filtered page fetch
- Extract paginated_games_provider.dart as standalone file
- fix: search debounce and F5 refresh animation
- Debounce search input 300ms to avoid per-keystroke API calls
- Fix _refreshLibrary to use paginated notifier instead of old allGamesProvider
- F5 refresh now correctly shows loading animation and clears cache
- fix: make _downloadedStates final per lint suggestion
Why: Adds a new feature or capability to the application.
lib/core/romm/romm_service.dart | 30 +++
lib/core/save/save_sync_service.dart | 133 ++++++++----
lib/providers/paginated_games_provider.dart | 174 +++++++++++++++
lib/ui/screens/library_screen.dart | 314 ++++++++++++++--------------
pubspec.lock | 2 +-
pubspec.yaml | 1 +
6 files changed, 458 insertions(+), 196 deletions(-)
lib/core/romm/romm_service.dartlib/core/save/save_sync_service.dartlib/providers/paginated_games_provider.dartlib/ui/screens/library_screen.dartpubspec.lockpubspec.yaml
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index bd6be2a..c268d2d 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -45,6 +45,11 @@ class RommService {
handler.next(e);
},
));
+ _dio.interceptors.add(InterceptorsWrapper(
+ onRequest: (RequestOptions options, RequestInterceptorHandler handler) {
+ handler.next(options);
+ },
+ ));
}
/// Returns the appropriate auth Options for each request.
@@ -136,6 +141,31 @@ class RommService {
return _fetchPaginatedGames(params);
}
+ Future<({List<Game> games, int total})> getGamesPage({
+ int offset = 0,
+ int limit = 50,
+ String? platformId,
+ String? search,
+ }) async {
+ final params = <String, dynamic>{
+ 'limit': limit,
+ 'offset': offset,
+ };
+ params['order_by'] = 'name';
+ params['order_dir'] = 'asc';
+ if (platformId != null) params['platform_ids'] = [int.parse(platformId)];
+ if (search != null && search.isNotEmpty) params['search_term'] = search;
+
+ final response = await _dio.get('/api/roms', queryParameters: params, options: _authOptions);
+ if (response.statusCode == 200) {
+ final Map<String, dynamic> data = response.data is Map ? response.data : {'items': response.data};
+ final List<dynamic> items = data['items'] ?? [];
+ final int total = data['total'] ?? items.length;
+ return (games: items.map((e) => Game.fromJson(e)).toList(), total: total);
+ }
+ throw DioException(requestOptions: response.requestOptions, response: response, type: DioExceptionType.badResponse);
+ }
+
Future<List<Game>> _fetchPaginatedGames(Map<String, dynamic> params) async {
int offset = 0;
const int limit = 100;
diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart
index 4b3139a..fd7b969 100644
--- a/lib/core/save/save_sync_service.dart
+++ b/lib/core/save/save_sync_service.dart
@@ -1,4 +1,6 @@
import 'dart:io';
+import 'package:crypto/crypto.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import '../romm/romm_models.dart';
import '../romm/romm_service.dart';
import '../storage/directory_service.dart';
@@ -145,28 +147,66 @@ class SaveSyncService {
}
}
+ String _hashKey(String gameId, String filename) =>
+ 'last_hash_${gameId}_$filename';
+
+ Future<String?> _getStoredHash(
+ String gameId, String filename) async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getString(_hashKey(gameId, filename));
+ }
+
+ Future<void> _storeHash(
+ String gameId, String filename, String hash) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_hashKey(gameId, filename), hash);
+ }
+
+ Future<String> _hashFile(File file) async {
+ final bytes = await file.readAsBytes();
+ return md5.convert(bytes).toString();
+ }
+
+ String _pullKey(String gameId) =>
+ 'last_pull_$gameId';
+
+ Future<DateTime?> _getLastPullTime(String gameId) async {
+ final prefs = await SharedPreferences.getInstance();
+ final stored = prefs.getString(_pullKey(gameId));
+ if (stored == null) return null;
+ return DateTime.tryParse(stored);
+ }
+
+ Future<void> _setLastPullTime(String gameId) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(
+ _pullKey(gameId),
+ DateTime.now().toIso8601String(),
+ );
+ }
+
/// Uploads all local save files for [game] to RomM.
///
/// If [sessionStart] is provided, only files modified after that time are uploaded.
/// Returns true if at least one file was uploaded successfully.
- Future<bool> pushSaves(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
+ Future<bool> pushSaves(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
try {
final strategy = getStrategyForSlug(game.platformSlug);
- if (strategy == null) {
- return false;
- }
+ if (strategy == null) return false;
- final files = await strategy.getSaveFiles(game, romPath, sessionStart: sessionStart, syncMode: syncMode);
- if (files.isEmpty) {
- return false;
- }
+ final files = await strategy.getSaveFiles(
+ game, romPath,
+ sessionStart: sessionStart,
+ syncMode: syncMode,
+ );
+ if (files.isEmpty) return false;
int uploaded = 0;
for (final file in files) {
File uploadFile = file;
bool isTempZip = false;
- // If it's a directory, zip it first
if (await FileSystemEntity.isDirectory(file.path)) {
final zipPath = '${file.path}.zip';
final encoder = ZipFileEncoder();
@@ -177,14 +217,30 @@ class SaveSyncService {
isTempZip = true;
}
- final ok = await _rommService.uploadSave(game.id, uploadFile);
- if (ok) uploaded++;
+ final filename = uploadFile.path
+ .split(RegExp(r'[/\\]'))
+ .last;
- // Clean up temp zip
- if (isTempZip && await uploadFile.exists()) {
- await uploadFile.delete();
- }
-}
+ final localHash = await _hashFile(uploadFile);
+ final storedHash = await _getStoredHash(game.id, filename);
+
+ if (storedHash != null && localHash == storedHash) {
+ if (isTempZip && await uploadFile.exists()) {
+ await uploadFile.delete();
+ }
+ continue;
+ }
+
+ final ok = await _rommService.uploadSave(game.id, uploadFile);
+ if (ok) {
+ uploaded++;
+ await _storeHash(game.id, filename, localHash);
+ }
+
+ if (isTempZip && await uploadFile.exists()) {
+ await uploadFile.delete();
+ }
+ }
return uploaded > 0;
} catch (e) {
@@ -198,35 +254,42 @@ class SaveSyncService {
Future<bool> pullSave(Game game, String romPath) async {
try {
final strategy = getStrategyForSlug(game.platformSlug);
- if (strategy == null) {
- return false;
- }
+ if (strategy == null) return false;
final save = await _rommService.getLatestSave(game.id);
- if (save == null) {
- return false;
- }
- // print('[Pull] getLatestSave result: $save');
+ if (save == null) return false;
- final downloadUrl = save['download_path'] as String? ?? save['url'] as String?;
- if (downloadUrl == null) {
- return false;
- }
+ // Check remote updated_at vs last pull time
+ final remoteUpdatedAt = DateTime.tryParse(
+ save['updated_at']?.toString() ?? '');
+ final lastPull = await _getLastPullTime(game.id);
- final bytes = await _rommService.downloadSave(downloadUrl);
- if (bytes == null) {
+ if (lastPull != null &&
+ remoteUpdatedAt != null &&
+ !remoteUpdatedAt.isAfter(lastPull)) {
+ // Remote hasn't changed since last pull, skip
return false;
}
- // print('[Pull] downloaded bytes: ${bytes?.length}');
- final filename = save['file_name'] as String? ??
- downloadUrl.split('/').last;
+ final downloadUrl = save['download_path'] as String?
+ ?? save['url'] as String?;
+ if (downloadUrl == null) return false;
- final ok = await strategy.restoreSave(game, romPath, bytes, filename);
- // print('[Pull] restoreSave result: $ok');
+ final bytes = await _rommService.downloadSave(
+ downloadUrl);
+ if (bytes == null) return false;
+
+ final filename = save['file_name'] as String?
+ ?? downloadUrl.split('/').last;
+
+ final ok = await strategy.restoreSave(
+ game, romPath, bytes, filename);
+
+ if (ok) {
+ await _setLastPullTime(game.id);
+ }
return ok;
} catch (e) {
- // print('[Pull] error: $e'); // Removed print statement
rethrow;
}
}
diff --git a/lib/providers/paginated_games_provider.dart b/lib/providers/paginated_games_provider.dart
new file mode 100644
index 0000000..af98da6
--- /dev/null
+++ b/lib/providers/paginated_games_provider.dart
@@ -0,0 +1,174 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../core/romm/romm_models.dart';
+import 'romm_provider.dart';
+
+class PaginatedGamesState {
+ final List<Game> games;
+ final bool isLoading;
+ final bool isLoadingMore;
+ final bool hasMore;
+ final int total;
+ final String? error;
+
+ const PaginatedGamesState({
+ this.games = const [],
+ this.isLoading = false,
+ this.isLoadingMore = false,
+ this.hasMore = true,
+ this.total = 0,
+ this.error,
+ });
+
+ PaginatedGamesState copyWith({
+ List<Game>? games,
+ bool? isLoading,
+ bool? isLoadingMore,
+ bool? hasMore,
+ int? total,
+ String? error,
+ }) =>
+ PaginatedGamesState(
+ games: games ?? this.games,
+ isLoading: isLoading ?? this.isLoading,
+ isLoadingMore: isLoadingMore ?? this.isLoadingMore,
+ hasMore: hasMore ?? this.hasMore,
+ total: total ?? this.total,
+ error: error,
+ );
+}
+
+class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
+ final Ref _ref;
+ static const int _pageSize = 50;
+
+ // Per-key cache: key = "$platformId|$search"
+ final Map<String, List<Game>> _cache = {};
+ final Map<String, int> _offsets = {};
+ final Map<String, int> _totals = {};
+
+ String? _currentPlatformId;
+ String? _currentSearch;
+
+ PaginatedGamesNotifier(this._ref) : super(const PaginatedGamesState());
+
+ String _key(String? platformId, String? search) =>
+ '${platformId ?? "all"}|${search ?? ""}';
+
+ Future<void> loadInitial({String? platformId, String? search}) async {
+ _currentPlatformId = platformId;
+ _currentSearch = search;
+ final key = _key(platformId, search);
+
+ // Serve from cache immediately if available
+ if (_cache.containsKey(key)) {
+ state = PaginatedGamesState(
+ games: _cache[key]!,
+ total: _totals[key] ?? _cache[key]!.length,
+ hasMore: (_offsets[key] ?? 0) < (_totals[key] ?? 0),
+ isLoading: false,
+ );
+ // Background refresh of first page only
+ _backgroundRefresh(platformId: platformId, search: search, key: key);
+ return;
+ }
+
+ // 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');
+ return;
+ }
+ try {
+ final result = await service.getGamesPage(
+ offset: 0,
+ limit: _pageSize,
+ platformId: platformId,
+ search: search,
+ );
+ _cache[key] = result.games;
+ _offsets[key] = result.games.length;
+ _totals[key] = result.total;
+ state = PaginatedGamesState(
+ games: result.games,
+ total: result.total,
+ hasMore: result.games.length < result.total,
+ isLoading: false,
+ );
+ } catch (e) {
+ state = state.copyWith(isLoading: false, error: e.toString());
+ }
+ }
+
+ Future<void> _backgroundRefresh({
+ required String? platformId,
+ required String? search,
+ required String key,
+ }) async {
+ final service = _ref.read(rommServiceProvider);
+ if (service == null) return;
+ try {
+ final result = await service.getGamesPage(
+ offset: 0,
+ limit: _pageSize,
+ platformId: platformId,
+ search: search,
+ );
+ // Only update if still on same key
+ if (_key(_currentPlatformId, _currentSearch) == key) {
+ _cache[key] = result.games;
+ _offsets[key] = result.games.length;
+ _totals[key] = result.total;
+ state = PaginatedGamesState(
+ games: result.games,
+ total: result.total,
+ hasMore: result.games.length < result.total,
+ isLoading: false,
+ );
+ }
+ } catch (_) {}
+ }
+
+ Future<void> loadMore() async {
+ if (state.isLoadingMore || !state.hasMore) return;
+ final service = _ref.read(rommServiceProvider);
+ if (service == null) return;
+ final key = _key(_currentPlatformId, _currentSearch);
+ final int offset = _offsets[key] ?? state.games.length;
+ state = state.copyWith(isLoadingMore: true);
+ try {
+ final result = await service.getGamesPage(
+ offset: offset,
+ limit: _pageSize,
+ platformId: _currentPlatformId,
+ search: _currentSearch,
+ );
+ final merged = [...state.games, ...result.games];
+ _cache[key] = merged;
+ _offsets[key] = merged.length;
+ _totals[key] = result.total;
+ state = PaginatedGamesState(
+ games: merged,
+ total: result.total,
+ hasMore: merged.length < result.total,
+ isLoadingMore: false,
+ );
+ } catch (e) {
+ state = state.copyWith(isLoadingMore: false, error: e.toString());
+ }
+ }
+
+ void reset() {
+ _cache.clear();
+ _offsets.clear();
+ _totals.clear();
+ _currentPlatformId = null;
+ _currentSearch = null;
+ state = const PaginatedGamesState();
+ }
+}
+
+final paginatedGamesProvider =
+ StateNotifierProvider<PaginatedGamesNotifier, PaginatedGamesState>((ref) {
+ return PaginatedGamesNotifier(ref);
+});
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index 7ec6261..45fd81b 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -3,11 +3,12 @@ import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:shared_preferences/shared_preferences.dart';
import '../../providers/library_provider.dart';
import '../../providers/download_provider.dart';
import '../../providers/romm_provider.dart';
+import '../../providers/paginated_games_provider.dart';
import '../../core/storage/directory_service.dart';
import '../../core/romm/romm_models.dart';
import '../../core/emulator/strategies/windows_strategy.dart';
@@ -25,36 +26,36 @@ class LibraryScreen extends ConsumerStatefulWidget {
}
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
- Map<String, bool> _downloadedStates = {};
- bool _downloadStatesLoaded = false;
+ final Map<String, bool> _downloadedStates = {};
late TextEditingController _searchController;
+ final FocusNode _focusNode = FocusNode();
+ final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
+ GlobalKey<RefreshIndicatorState>();
+ late ScrollController _scrollController;
@override
void initState() {
super.initState();
_searchController = TextEditingController(text: ref.read(searchQueryProvider));
+ _scrollController = ScrollController();
+ _scrollController.addListener(_onScroll);
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _focusNode.requestFocus();
+ });
}
@override
void dispose() {
_searchController.dispose();
+ _focusNode.dispose();
+ _scrollController.dispose();
super.dispose();
}
- Future<void> _loadDownloadStates(
- DirectoryService dirService, List<Game> games) async {
- if (_downloadStatesLoaded) return;
- final results = await Future.wait(
- games.map((game) async {
- final isDownloaded = await dirService.isRomDownloaded(game);
- return MapEntry(game.id, isDownloaded);
- }),
- );
- if (mounted) {
- setState(() {
- _downloadedStates = Map.fromEntries(results);
- _downloadStatesLoaded = true;
- });
+ void _onScroll() {
+ if (!_scrollController.hasClients) return;
+ if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 600) {
+ ref.read(paginatedGamesProvider.notifier).loadMore();
}
}
@@ -68,6 +69,15 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
}
}
+ Future<void> _refreshLibrary() async {
+ ref.invalidate(platformsProvider);
+ ref.read(paginatedGamesProvider.notifier).reset();
+ await ref.read(paginatedGamesProvider.notifier).loadInitial(
+ platformId: ref.read(selectedPlatformIdProvider)?.toString(),
+ search: ref.read(searchQueryProvider).isEmpty ? null : ref.read(searchQueryProvider),
+ );
+ }
+
void _startDownload(BuildContext context, WidgetRef ref, Game game) {
final service = ref.read(rommServiceProvider);
if (service == null) {
@@ -388,9 +398,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
Widget build(BuildContext context) {
final platformsAsync = ref.watch(platformsProvider);
final selectedPlatformId = ref.watch(selectedPlatformIdProvider);
- final searchQuery = ref.watch(searchQueryProvider);
- final gamesAsync = ref.watch(allGamesProvider);
- final filteredGames = ref.watch(filteredGamesProvider);
+ final paginatedState = ref.watch(paginatedGamesProvider);
final cardAspectRatio = ref.watch(cardAspectRatioProvider);
final columnCount = ref.watch(columnCountProvider);
final cardSpacing = ref.watch(cardSpacingProvider);
@@ -399,12 +407,27 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
final rommConfigAsync = ref.watch(rommConfigProvider);
final directoryServiceAsync = ref.watch(directoryServiceProvider);
+ // Trigger initial load once service becomes available
+ ref.listen(rommServiceProvider, (prev, next) {
+ if (prev == null && next != null) {
+ ref.read(paginatedGamesProvider.notifier).loadInitial(platformId: null);
+ }
+ });
+
+ // Reload when platform changes
+ ref.listen<int?>(selectedPlatformIdProvider, (prev, next) {
+ if (prev != next) {
+ ref.read(paginatedGamesProvider.notifier).loadInitial(
+ platformId: next?.toString(),
+ );
+ }
+ });
+
final appBarTitle = rommConfigAsync.when(
data: (config) {
final uri = Uri.tryParse(config.baseUrl);
final host = uri?.host ?? config.baseUrl;
- final totalGames = gamesAsync.asData?.value.length;
- final gameCountStr = totalGames != null ? ' • $totalGames games' : '';
+ final gameCountStr = ' • ${paginatedState.total} games';
return 'Freegosy • $host$gameCountStr';
},
loading: () => 'Freegosy',
@@ -413,159 +436,130 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
return Scaffold(
appBar: AppBar(title: Text(appBarTitle)),
- body: ExcludeSemantics(
- child: Column(
- children: [
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
- child: TextField(
- controller: _searchController,
- decoration: InputDecoration(
- hintText: 'Search games...',
- prefixIcon: const Icon(Icons.search),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8.0),
+ body: KeyboardListener(
+ focusNode: _focusNode,
+ autofocus: true,
+ onKeyEvent: (KeyEvent event) {
+ if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.f5) {
+ _refreshIndicatorKey.currentState?.show();
+ }
+ },
+ child: ExcludeSemantics(
+ child: Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
+ child: TextField(
+ controller: _searchController,
+ decoration: InputDecoration(
+ hintText: 'Search games...',
+ prefixIcon: const Icon(Icons.search),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)),
),
+ onChanged: (value) {
+ ref.read(searchQueryProvider.notifier).state = value;
+ ref.read(paginatedGamesProvider.notifier).loadInitial(
+ platformId: ref.read(selectedPlatformIdProvider)?.toString(),
+ search: value.isEmpty ? null : value,
+ );
+ },
),
- onChanged: (value) {
- ref.read(searchQueryProvider.notifier).state = value;
- },
),
- ),
- platformsAsync.when(
- data: (platforms) => PlatformFilterBar(
- platforms: platforms,
- selectedPlatformId: selectedPlatformId,
- onSelected: (platform) {
- ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
- },
- ),
- loading: () => const LinearProgressIndicator(),
- error: (e, s) => Text('Error loading platforms: $e'),
- ),
- Expanded(
- child: gamesAsync.when(
- loading: () => buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing, context),
- error: (e, s) => Center(
- child: Padding(
- padding: const EdgeInsets.all(16.0),
- child: Text(
- 'Error loading games: $e',
- textAlign: TextAlign.center,
- style: const TextStyle(color: Colors.red),
- ),
- ),
+ platformsAsync.when(
+ data: (platforms) => PlatformFilterBar(
+ platforms: platforms,
+ selectedPlatformId: selectedPlatformId,
+ onSelected: (platform) {
+ ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
+ },
),
- data: (_) {
- final gamesCount = filteredGames.length;
- final countDisplayText =
- (selectedPlatformId == null && searchQuery.isEmpty)
- ? 'Showing all $gamesCount games'
- : 'Showing $gamesCount games';
-
- final dirService = directoryServiceAsync.asData?.value;
- if (dirService != null && !_downloadStatesLoaded) {
- final games = ref.read(allGamesProvider).asData?.value ?? [];
- _loadDownloadStates(dirService, games);
- }
-
- return Column(
- children: [
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0),
- child: Align(
- alignment: Alignment.centerRight,
- child: Text(
- countDisplayText,
- style: const TextStyle(fontSize: 12, color: Colors.grey),
+ loading: () => const LinearProgressIndicator(),
+ error: (e, s) => Text('Error loading platforms: $e'),
+ ),
+ Expanded(
+ child: paginatedState.isLoading
+ ? buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing, context)
+ : paginatedState.error != null
+ ? Center(child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text('Error: ${paginatedState.error}', style: const TextStyle(color: Colors.red)),
+ ))
+ : Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0),
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: Text(
+ selectedPlatformId == null
+ ? 'Showing ${paginatedState.games.length} of ${paginatedState.total} games'
+ : 'Showing ${paginatedState.games.length} of ${paginatedState.total} games',
+ style: const TextStyle(fontSize: 12, color: Colors.grey),
+ ),
+ ),
),
- ),
- ),
- Expanded(
- child: RefreshIndicator(
- onRefresh: () async {
- setState(() {
- _downloadStatesLoaded = false;
- _downloadedStates = {};
- });
- final prefs = await SharedPreferences.getInstance();
- await prefs.remove('cached_games');
- await prefs.remove('cached_platforms');
- await prefs.remove('cached_games_time');
- await prefs.remove('cached_platforms_time');
- await prefs.remove('cache_size_exceeded');
- ref.invalidate(allGamesProvider);
- ref.invalidate(platformsProvider);
- await ref.read(allGamesProvider.future);
- },
- child: filteredGames.isEmpty
- ? const CustomScrollView(
- slivers: [
- SliverFillRemaining(
- child: Center(child: Text('No games found')),
+ Expanded(
+ child: RefreshIndicator(
+ key: _refreshIndicatorKey,
+ onRefresh: _refreshLibrary,
+ child: paginatedState.games.isEmpty
+ ? const CustomScrollView(slivers: [
+ SliverFillRemaining(child: Center(child: Text('No games found'))),
+ ])
+ : GridView.builder(
+ controller: _scrollController,
+ padding: const EdgeInsets.all(12),
+ cacheExtent: 800,
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: columnCount,
+ crossAxisSpacing: cardSpacing,
+ mainAxisSpacing: cardSpacing,
+ mainAxisExtent: calculateCardHeight(columnCount, cardSpacing, cardAspectRatio, context),
),
- ],
- )
- : GridView.builder(
- padding: const EdgeInsets.all(12),
- cacheExtent: 800,
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: columnCount,
- crossAxisSpacing: cardSpacing,
- mainAxisSpacing: cardSpacing,
- mainAxisExtent: calculateCardHeight(
- columnCount, cardSpacing, cardAspectRatio, context),
- ),
- itemCount: filteredGames.length,
- itemBuilder: (context, index) {
- final game = filteredGames[index];
- final dirService = directoryServiceAsync.asData?.value;
- final isWindowsGame = ['windows', 'pc', 'win']
- .contains(game.platformSlug?.toLowerCase() ?? '');
- final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
-
- if (dirService == null) {
+ itemCount: paginatedState.games.length + (paginatedState.isLoadingMore ? 1 : 0),
+ itemBuilder: (context, index) {
+ if (index == paginatedState.games.length) {
+ return const Center(child: Padding(
+ padding: EdgeInsets.all(16),
+ child: CircularProgressIndicator(),
+ ));
+ }
+ final game = paginatedState.games[index];
+ final dirService = directoryServiceAsync.asData?.value;
+ final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
+ final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
+ if (dirService == null) {
+ return GestureDetector(
+ onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null,
+ child: GameCard(
+ game: game, coverUrl: coverUrl, showTitle: showTitle,
+ showButtonsOnHover: showButtonsOnHover,
+ onDownload: () => _startDownload(context, ref, game),
+ onLaunch: () => _handleLaunch(context, ref, game),
+ onSyncSaves: () => _handleSyncSaves(context, ref, game),
+ ),
+ );
+ }
return GestureDetector(
- onLongPress: isWindowsGame
- ? () => _handleWindowsConfig(context, ref, game)
- : null,
+ onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null,
child: GameCard(
- game: game,
- coverUrl: coverUrl,
- showTitle: showTitle,
- showButtonsOnHover: showButtonsOnHover,
+ game: game, coverUrl: coverUrl,
+ isDownloaded: _downloadedStates[game.id] ?? false,
+ showTitle: showTitle, showButtonsOnHover: showButtonsOnHover,
onDownload: () => _startDownload(context, ref, game),
onLaunch: () => _handleLaunch(context, ref, game),
onSyncSaves: () => _handleSyncSaves(context, ref, game),
),
);
- }
-
- return GestureDetector(
- onLongPress: isWindowsGame
- ? () => _handleWindowsConfig(context, ref, game)
- : null,
- child: GameCard(
- game: game,
- coverUrl: coverUrl,
- isDownloaded: _downloadedStates[game.id] ?? false,
- showTitle: showTitle,
- showButtonsOnHover: showButtonsOnHover,
- onDownload: () => _startDownload(context, ref, game),
- onLaunch: () => _handleLaunch(context, ref, game),
- onSyncSaves: () => _handleSyncSaves(context, ref, game),
- ),
- );
- },
- ),
- ),
+ },
+ ),
+ ),
+ ),
+ ],
),
- ],
- );
- },
),
- ),
- ],
+ ],
+ ),
),
),
);
diff --git a/pubspec.lock b/pubspec.lock
index 16720af..69cef31 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -90,7 +90,7 @@ packages:
source: hosted
version: "0.3.3+8"
crypto:
- dependency: transitive
+ dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
diff --git a/pubspec.yaml b/pubspec.yaml
index 9a1ebc5..481f0f0 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -35,6 +35,7 @@ dependencies:
path_provider: ^2.1.2
shared_preferences: ^2.2.2
package_info_plus: ^5.0.1
+ crypto: ^3.0.3
cached_network_image: ^3.3.1
# The following adds the Cupertino Icons font to your application.