Skip to content

Navigation Menu

Sign in
Sign up

commit 1c36b87

abduznik edited this page May 23, 2026 · 1 revision

feat: paginated game loading with server-side platform filtering (#7)

Commit: 1c36b876fc1d9dadf38a161a035e01b6503acc73

Author: abduznik

Date: 2026年03月28日

Message

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

Files Changed

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.dart
  • lib/core/save/save_sync_service.dart
  • lib/providers/paginated_games_provider.dart
  • lib/ui/screens/library_screen.dart
  • pubspec.lock
  • pubspec.yaml

Diff

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.

Clone this wiki locally

AltStyle によって変換されたページ (->オリジナル) /