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