-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 357b925
abduznik edited this page May 23, 2026
·
1 revision
Commit: 357b92581e05eb6a843187fb711a78910909828d
Author: abduznik
Date: 2026年03月25日
-
WIP: Ui improvements
-
feat: customizable library display system
- Added preset system (Windows, Steam Deck, Cozy, Compact, Custom)
- Added column count slider (2-8 columns per row)
- Added card shape modes (Square 1.0, Portrait 0.72, Tall 0.58)
- Added card spacing options (Tight, Normal, Airy)
- Added show title toggle
- Added hover-only buttons toggle
- Fixed card height calculation using mainAxisExtent instead of childAspectRatio
- Fixed aspect ratio not affecting card height due to ref.read vs ref.watch
- Added shimmer skeleton loading grid instead of plain spinner
- Added pull to refresh on game grid
- Added long press context menu with download, launch and sync saves
- Clean game display names stripping ROM filename junk and region codes
- Fixed blank gap between filter bar and game grid
- All display settings persist via SharedPreferences and load on startup
- Settings screen correctly reflects saved values on reopen
- Removed all debugPrint statements from production code
-
fix: resolve linting issues and satisfy curly_braces_in_flow_control_structures
-
fix: performance and stability improvements
- Fixed accessibility tree flooding by adding ExcludeSemantics at app root level in app.dart
- Fixed shimmer skeleton causing 60fps full grid rebuilds by isolating animation per card with _SkeletonCard widget
- Fixed settings screen FutureBuilder per emulator replaced with single cached load in initState
- Fixed 627 FutureBuilders per game card replaced with single cached Map loaded once in _LibraryScreenState
- Added game library cache with 7 day expiry and 10MB size limit
- Cache skips gracefully for libraries exceeding 10MB size limit
- Pull to refresh invalidates cache and forces fresh fetch
- Background refresh silently updates cache after serving cached data on launch
- Removed all debugPrint statements from production code
- perf: improve game grid scroll performance
- Added RepaintBoundary to GameCard to cache rendered cards
- Added cacheExtent 500 to GridView for pre-rendering
- Added BouncingScrollPhysics for smoother scroll feel
- Fixed _loadDownloadStates to run parallel with Future.wait instead of 627 sequential file checks blocking main thread
- test: Update GameCard test to reflect architectural change
Why: Adds a new feature or capability to the application.
.gitignore | 1 +
lib/app.dart | 35 +-
lib/core/downloader/download_service.dart | 15 -
lib/core/emulator/github_release_service.dart | 5 -
lib/core/emulator/strategies/windows_strategy.dart | 6 -
lib/core/romm/romm_models.dart | 21 ++
lib/core/romm/romm_service.dart | 25 --
lib/core/save/save_strategy.dart | 3 +-
lib/core/save/save_sync_service.dart | 13 -
.../save/strategies/dolphin_save_strategy.dart | 3 -
lib/core/save/strategies/eden_save_strategy.dart | 14 +-
lib/core/save/strategies/pcsx2_save_strategy.dart | 3 -
.../save/strategies/retroarch_save_strategy.dart | 3 -
lib/core/save/strategies/rpcs3_save_strategy.dart | 8 -
.../save/strategies/windows_save_strategy.dart | 7 +-
lib/core/save/strategies/xenia_save_strategy.dart | 4 -
lib/core/storage/directory_service.dart | 3 -
lib/core/windows/pcgamingwiki_service.dart | 8 +-
lib/core/windows/windows_game_service.dart | 3 -
lib/main.dart | 1 +
lib/providers/library_provider.dart | 258 +++++++++++++-
lib/providers/romm_provider.dart | 2 -
lib/ui/screens/download_screen.dart | 34 +-
lib/ui/screens/library_screen.dart | 334 ++++++++++++++----
lib/ui/screens/settings_screen.dart | 383 +++++++++++++++------
lib/ui/widgets/game_card.dart | 231 ++++++++-----
lib/ui/widgets/platform_filter_bar.dart | 2 +-
test/game_card_test.dart | 30 ++
28 files changed, 1050 insertions(+), 405 deletions(-)
.gitignorelib/app.dartlib/core/downloader/download_service.dartlib/core/emulator/github_release_service.dartlib/core/emulator/strategies/windows_strategy.dartlib/core/romm/romm_models.dartlib/core/romm/romm_service.dartlib/core/save/save_strategy.dartlib/core/save/save_sync_service.dartlib/core/save/strategies/dolphin_save_strategy.dartlib/core/save/strategies/eden_save_strategy.dartlib/core/save/strategies/pcsx2_save_strategy.dartlib/core/save/strategies/retroarch_save_strategy.dartlib/core/save/strategies/rpcs3_save_strategy.dartlib/core/save/strategies/windows_save_strategy.dartlib/core/save/strategies/xenia_save_strategy.dartlib/core/storage/directory_service.dartlib/core/windows/pcgamingwiki_service.dartlib/core/windows/windows_game_service.dartlib/main.dartlib/providers/library_provider.dartlib/providers/romm_provider.dartlib/ui/screens/download_screen.dartlib/ui/screens/library_screen.dartlib/ui/screens/settings_screen.dartlib/ui/widgets/game_card.dartlib/ui/widgets/platform_filter_bar.darttest/game_card_test.dart
diff --git a/.gitignore b/.gitignore index 3820a95..ea0b2ab 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +.aider* diff --git a/lib/app.dart b/lib/app.dart index d08c16a..0b9a161 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,6 +14,7 @@ class FreegosyApp extends ConsumerStatefulWidget { class _FreegosyAppState extends ConsumerState<FreegosyApp> { int _currentIndex = 0; + bool _settingsLoaded = false; final List<Widget> _screens = const [ LibraryScreen(), @@ -24,15 +25,38 @@ class _FreegosyAppState extends ConsumerState<FreegosyApp> { @override void initState() { super.initState(); - // Eagerly load persisted card aspect ratio - ref.read(cardAspectRatioLoaderProvider); - ref.read(retroarchSyncModeLoaderProvider); + Future.wait([ + ref.read(cardAspectRatioLoaderProvider.future), + ref.read(retroarchSyncModeLoaderProvider.future), + ref.read(columnCountLoaderProvider.future), + ref.read(cardSpacingLoaderProvider.future), + ref.read(showTitleLoaderProvider.future), + ref.read(showButtonsOnHoverLoaderProvider.future), + ref.read(activePresetLoaderProvider.future), + ]).then((_) { + if (mounted) { + setState(() => _settingsLoaded = true); + } + }); } @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Freegosy', + if (!_settingsLoaded) { + return const MaterialApp( + home: Scaffold( + backgroundColor: Color(0xFF0f0f0f), + body: Center( + child: CircularProgressIndicator( + color: Colors.deepPurple, + ), + ), + ), + ); + } + return ExcludeSemantics( + child: MaterialApp( + title: 'Freegosy', theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( @@ -99,6 +123,7 @@ class _FreegosyAppState extends ConsumerState<FreegosyApp> { ], ), ), + ), ); } } diff --git a/lib/core/downloader/download_service.dart b/lib/core/downloader/download_service.dart index b3e5a42..e83fab2 100644 --- a/lib/core/downloader/download_service.dart +++ b/lib/core/downloader/download_service.dart @@ -2,7 +2,6 @@ import 'package:dio/dio.dart'; import 'dart:async'; import 'dart:io'; import 'package:archive/archive_io.dart'; -import 'package:flutter/rendering.dart'; import 'package:freegosy/core/storage/directory_service.dart'; import 'package:freegosy/core/romm/romm_models.dart'; @@ -135,19 +134,5 @@ class DownloadService { // Delete the archive after extraction await File(zipPath).delete(); - - // Log the largest file found (main ROM) - File? largestFile; - int largestSize = 0; - await for (final entity in Directory(extractDir).list(recursive: true)) { - if (entity is File) { - final size = await entity.length(); - if (size > largestSize) { - largestSize = size; - largestFile = entity; - } - } - } - debugPrint('[DownloadService] multi-file extracted to $extractDir, main ROM: ${largestFile?.path}'); } } \ No newline at end of file diff --git a/lib/core/emulator/github_release_service.dart b/lib/core/emulator/github_release_service.dart index 404a530..acb8b57 100644 --- a/lib/core/emulator/github_release_service.dart +++ b/lib/core/emulator/github_release_service.dart @@ -1,5 +1,4 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; class GithubReleaseService { final Dio _dio; @@ -22,7 +21,6 @@ class GithubReleaseService { if (response.statusCode != 200) return null; final assets = response.data['assets'] as List<dynamic>; - debugPrint('[GithubReleaseService] $repo — ${assets.length} assets found'); for (final asset in assets) { final name = (asset['name'] as String).toLowerCase(); @@ -36,14 +34,11 @@ class GithubReleaseService { final matchesExcluded = excluded.any((f) => name.contains(f.toLowerCase())); if (matchesExcluded) continue; - debugPrint('[GithubReleaseService] matched asset: $name'); return url; } - debugPrint('[GithubReleaseService] no matching asset found for $repo'); return null; } catch (e) { - debugPrint('[GithubReleaseService] error fetching $repo: $e'); return null; } } diff --git a/lib/core/emulator/strategies/windows_strategy.dart b/lib/core/emulator/strategies/windows_strategy.dart index 17cf244..391e944 100644 --- a/lib/core/emulator/strategies/windows_strategy.dart +++ b/lib/core/emulator/strategies/windows_strategy.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:freegosy/core/emulator/emulator_strategy.dart'; import 'package:freegosy/core/romm/romm_models.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -60,7 +59,6 @@ class WindowsStrategy extends EmulatorStrategy { // If stored override no longer exists on disk, discard and auto-detect if (exePath != null && exePath.isNotEmpty && !await File(exePath).exists()) { - debugPrint('[WindowsStrategy] override exe not found at $exePath, falling back to auto-detect'); exePath = null; } @@ -81,7 +79,6 @@ class WindowsStrategy extends EmulatorStrategy { ); } - debugPrint('[WindowsStrategy] launching: $exePath'); final process = await Process.start( exePath, [], @@ -94,14 +91,11 @@ class WindowsStrategy extends EmulatorStrategy { .catchError((_) => -99999); // timeout = still running = fine if (exitCode != -99999 && exitCode != 0) { - debugPrint('[WindowsStrategy] ${game.name} exited with code $exitCode'); throw Exception( '${game.name} crashed immediately (exit code $exitCode). ' 'This is likely due to missing DirectX, Visual C++ redistributables, or other dependencies.', ); } - - debugPrint('[WindowsStrategy] ${game.name} still running after 5s — OK'); } @override diff --git a/lib/core/romm/romm_models.dart b/lib/core/romm/romm_models.dart index c9d6d00..afca4b0 100644 --- a/lib/core/romm/romm_models.dart +++ b/lib/core/romm/romm_models.dart @@ -16,6 +16,27 @@ class Game { bool get isMultiFile => hasMultipleFiles; + String get displayName { + String cleaned = name; + + // Remove leading hex IDs like 00040000000EC400 + cleaned = cleaned.replaceAll(RegExp(r'^[0-9A-Fa-f]{16}\s*'), ''); + + // Remove region/version codes in parentheses like (CTR-P-BZLP) (v0.0.0) (En) + cleaned = cleaned.replaceAll(RegExp(r'\([^)]*\)'), ''); + + // Remove region/version codes in brackets like [!] [b] [T+Eng] + cleaned = cleaned.replaceAll(RegExp(r'\[[^\]]*\]'), ''); + + // Remove trailing dots, dashes, underscores and whitespace + cleaned = cleaned.replaceAll(RegExp(r'[\s._-]+$'), ''); + + // Collapse multiple spaces into one + cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' '); + + return cleaned.trim().isEmpty ? name : cleaned.trim(); + } + Game({ required this.id, required this.name, diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart index 54fcc69..61e4934 100644 --- a/lib/core/romm/romm_service.dart +++ b/lib/core/romm/romm_service.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:dio/dio.dart'; -import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'romm_models.dart'; @@ -19,14 +18,6 @@ class RommService { connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), )) { - debugPrint('[RommService] created — baseUrl=${_normalizeBaseUrl(config.baseUrl)} user=${config.username} hasToken=${config.token != null && config.token!.isNotEmpty}'); - // _dio.interceptors.add(LogInterceptor( - // requestHeader: true, - // responseHeader: false, - // responseBody: true, - // requestBody: true, - // logPrint: (o) => debugPrint('[DIO] $o'), - // )); // If the server rejects the Bearer token with 403, retry once with Basic auth. _dio.interceptors.add(InterceptorsWrapper( onError: (DioException e, ErrorInterceptorHandler handler) async { @@ -35,7 +26,6 @@ class RommService { e.requestOptions.data is! FormData && config.token != null && config.token!.isNotEmpty) { - debugPrint('[RommService] Bearer got 403 — retrying with Basic auth'); final basic = 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}'; final opts = e.requestOptions ..headers['Authorization'] = basic @@ -56,10 +46,8 @@ class RommService { Options get _authOptions { final token = config.token; if (token != null && token.isNotEmpty) { - debugPrint('[RommService] _authOptions using Bearer token'); return Options(headers: {'Authorization': 'Bearer $token'}); } - debugPrint('[RommService] _authOptions using Basic auth user=${config.username} passLen=${config.password.length}'); final basic = 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}'; return Options(headers: {'Authorization': basic}); } @@ -68,13 +56,11 @@ class RommService { /// stores the Bearer token in SharedPreferences, and returns it. static Future<String> fetchToken(String baseUrl, String username, String password) async { final normalizedUrl = _normalizeBaseUrl(baseUrl); - debugPrint('[fetchToken] POST $normalizedUrl/api/token user=$username'); final dio = Dio(BaseOptions( baseUrl: normalizedUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), )); - dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true, logPrint: (o) => debugPrint('[DIO/token] $o'))); final response = await dio.post( '/api/token', data: { @@ -85,14 +71,12 @@ class RommService { }, options: Options(contentType: 'application/x-www-form-urlencoded'), ); - debugPrint('[fetchToken] response status=${response.statusCode} data=${response.data}'); final token = response.data['access_token'] as String?; if (token == null || token.isEmpty) { throw Exception('Login failed: no access_token in response'); } final prefs = await SharedPreferences.getInstance(); await prefs.setString('rommAuthToken', token); - debugPrint('[fetchToken] token stored OK'); return token; } @@ -157,9 +141,6 @@ class RommService { final Map<String, dynamic> data = response.data is Map ? response.data : {'items': response.data}; final List<dynamic> items = data['items'] ?? []; total = data['total'] ?? items.length; - if (offset == 0 && items.isNotEmpty) { - debugPrint('[RommService] sample raw game: ${items.first}'); - } allGames.addAll(items.map((item) => Game.fromJson(item)).toList()); offset += limit; } else { @@ -201,8 +182,6 @@ class RommService { ? config.baseUrl.substring(0, config.baseUrl.length - 1) : config.baseUrl; - debugPrint('[RommService] getDownloadUrl: name=${game.name} fileName=${game.fileName} fsName=${game.fsName} isMultiFile=${game.isMultiFile}'); - final name = game.fileName ?? game.fsName ?? game.name; final encoded = Uri.encodeComponent(name); return '$baseUrl/api/roms/${game.id}/content/$encoded'; @@ -233,10 +212,8 @@ class RommService { final ok = response.statusCode != null && response.statusCode! >= 200 && response.statusCode! < 300; - debugPrint('[RommService] uploadSave ${ok ? 'ok' : 'failed'} status=${response.statusCode} file=$fileName'); return ok; } catch (e) { - debugPrint('[RommService] uploadSave error: $e'); return false; } } @@ -273,7 +250,6 @@ class RommService { }); return sorted.first; } catch (e) { - debugPrint('[RommService] getLatestSave error: $e'); return null; } } @@ -295,7 +271,6 @@ class RommService { } return null; } catch (e) { - debugPrint('[RommService] downloadSave error: $e'); return null; } } diff --git a/lib/core/save/save_strategy.dart b/lib/core/save/save_strategy.dart index 08a9265..e854ffd 100644 --- a/lib/core/save/save_strategy.dart +++ b/lib/core/save/save_strategy.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'dart:typed_data'; -import 'package:flutter/widgets.dart'; import '../romm/romm_models.dart'; @@ -33,7 +32,7 @@ abstract class SaveStrategy { if (await bak.exists()) await bak.rename('$path.bak1'); await file.copy('$path.bak'); } catch (e) { - debugPrint('[SaveStrategy] backupSave error for $path: $e'); + // Error handled silently } } diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart index 1a87b9c..ddda8da 100644 --- a/lib/core/save/save_sync_service.dart +++ b/lib/core/save/save_sync_service.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import '../romm/romm_models.dart'; import '../romm/romm_service.dart'; import '../storage/directory_service.dart'; @@ -104,13 +103,11 @@ class SaveSyncService { try { final strategy = getStrategyForSlug(game.platformSlug); if (strategy == null) { - debugPrint('[SaveSyncService] no strategy for ${game.platformSlug}'); return false; } final files = await strategy.getSaveFiles(game, romPath, sessionStart: sessionStart, syncMode: syncMode); if (files.isEmpty) { - debugPrint('[SaveSyncService] no save files found for ${game.name}'); return false; } @@ -139,10 +136,8 @@ class SaveSyncService { } } - debugPrint('[SaveSyncService] pushed $uploaded/${files.length} saves for ${game.name}'); return uploaded > 0; } catch (e) { - debugPrint('[SaveSyncService] pushSaves error: $e'); return false; } } @@ -154,38 +149,30 @@ class SaveSyncService { try { final strategy = getStrategyForSlug(game.platformSlug); if (strategy == null) { - debugPrint('[SaveSyncService] no strategy for ${game.platformSlug}'); return false; } final save = await _rommService.getLatestSave(game.id); if (save == null) { - debugPrint('[SaveSyncService] no cloud save for ${game.name}'); return false; } final downloadUrl = save['download_path'] as String? ?? save['url'] as String?; if (downloadUrl == null) { - debugPrint('[SaveSyncService] save has no download URL'); return false; } final bytes = await _rommService.downloadSave(downloadUrl); if (bytes == null) { - debugPrint('[SaveSyncService] failed to download save bytes'); return false; } final filename = save['file_name'] as String? ?? downloadUrl.split('/').last; - debugPrint('[SaveSyncService] calling restoreSave: filename=$filename romPath=$romPath bytesLen=${bytes.length}'); final ok = await strategy.restoreSave(game, romPath, bytes, filename); - debugPrint('[SaveSyncService] restoreSave returned: $ok'); - debugPrint('[SaveSyncService] pullSave ${ok ? 'ok' : 'failed'} for ${game.name}'); return ok; } catch (e) { - debugPrint('[SaveSyncService] pullSave error: $e'); rethrow; } } diff --git a/lib/core/save/strategies/dolphin_save_strategy.dart b/lib/core/save/strategies/dolphin_save_strategy.dart index 971cf22..be16d8f 100644 --- a/lib/core/save/strategies/dolphin_save_strategy.dart +++ b/lib/core/save/strategies/dolphin_save_strategy.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'dart:typed_data'; -import 'package:flutter/rendering.dart'; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; @@ -68,10 +67,8 @@ Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionSta final targetPath = '$saveDir/$filename'; await backupSave(targetPath); await File(targetPath).writeAsBytes(data); - debugPrint('[DolphinSaveStrategy] restored $filename to $targetPath'); return true; } catch (e) { - debugPrint('[DolphinSaveStrategy] restoreSave error: $e'); return false; } } diff --git a/lib/core/save/strategies/eden_save_strategy.dart b/lib/core/save/strategies/eden_save_strategy.dart index 3a83909..9274893 100644 --- a/lib/core/save/strategies/eden_save_strategy.dart +++ b/lib/core/save/strategies/eden_save_strategy.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive.dart'; -import 'package:flutter/widgets.dart'; import '../../romm/romm_models.dart'; import '../save_strategy.dart'; @@ -52,7 +51,6 @@ class EdenSaveStrategy extends SaveStrategy { await raf.close(); } } catch (e) { - debugPrint('[EdenSaveStrategy] XCI parse error: $e'); return null; } } @@ -85,7 +83,7 @@ class EdenSaveStrategy extends SaveStrategy { } } } catch (e) { - debugPrint('[EdenSaveStrategy] scan error: $e'); + // Error handled silently } return bestTitleId; @@ -101,7 +99,6 @@ class EdenSaveStrategy extends SaveStrategy { if (appData.isEmpty || appData.contains('%APPDATA%')) return null; return '$appData/eden/nand/user/save/0000000000000000'; } catch (e) { - debugPrint('[EdenSaveStrategy] APPDATA lookup error: $e'); return null; } } @@ -157,7 +154,7 @@ class EdenSaveStrategy extends SaveStrategy { return '${profileDirs.first.path}/$titleId'; } } catch (e) { - debugPrint('[EdenSaveStrategy] getSaveDir error: $e'); + // Error handled silently } return null; } @@ -178,13 +175,10 @@ Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionSta @override Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async { try { - debugPrint('[EdenSaveStrategy] restoreSave called: filename=$filename destPath=$destPath'); final titleMatch = _titleIdRegex.firstMatch(filename.toUpperCase()); - debugPrint('[EdenSaveStrategy] titleMatch from filename: ${titleMatch?.group(0)}'); final saveDir = titleMatch != null ? await _getSaveDirForTitleId(titleMatch.group(0)!) : await getSaveDir(game, destPath); - debugPrint('[EdenSaveStrategy] saveDir resolved: $saveDir'); if (saveDir == null) return false; final dir = Directory(saveDir); @@ -198,10 +192,8 @@ Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionSta final targetPath = '$saveDir/$filename'; await backupSave(targetPath); await File(targetPath).writeAsBytes(data); - debugPrint('[EdenSaveStrategy] restored $filename to $targetPath'); return true; } catch (e) { - debugPrint('[EdenSaveStrategy] restoreSave error: $e'); rethrow; } } @@ -225,10 +217,8 @@ Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionSta await Directory(outPath).create(recursive: true); } } - debugPrint('[EdenSaveStrategy] extracted ${archive.length} entries to $destDir'); return true; } catch (e) { - debugPrint('[EdenSaveStrategy] zip extraction error: $e'); return false; } } diff --git a/lib/core/save/strategies/pcsx2_save_strategy.dart b/lib/core/save/strategies/pcsx2_save_strategy.dart index d70a186..7a44a61 100644 --- a/lib/core/save/strategies/pcsx2_save_strategy.dart +++ b/lib/core/save/strategies/pcsx2_save_strategy.dart @@ -97,7 +97,6 @@ class Pcsx2SaveStrategy extends SaveStrategy { final outFile = File(targetPath); await outFile.parent.create(recursive: true); await outFile.writeAsBytes(entry.content as List<int>); - debugPrint('[Pcsx2SaveStrategy] restored ${entry.name} to $targetPath'); } } return true; @@ -111,10 +110,8 @@ class Pcsx2SaveStrategy extends SaveStrategy { final targetPath = '$targetDir\\$filename'; await backupSave(targetPath); await File(targetPath).writeAsBytes(data); - debugPrint('[Pcsx2SaveStrategy] restored $filename to $targetPath'); return true; } catch (e) { - debugPrint('[Pcsx2SaveStrategy] restoreSave error: $e'); return false; } } diff --git a/lib/core/save/strategies/retroarch_save_strategy.dart b/lib/core/save/strategies/retroarch_save_strategy.dart index 50ac4cd..b9accd9 100644 --- a/lib/core/save/strategies/retroarch_save_strategy.dart +++ b/lib/core/save/strategies/retroarch_save_strategy.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'dart:typed_data'; -import 'package:flutter/rendering.dart'; import '../../romm/romm_models.dart'; import '../../storage/directory_service.dart'; @@ -110,10 +109,8 @@ class RetroArchSaveStrategy extends SaveStrategy { final targetPath = '$targetDir/$filename'; await backupSave(targetPath); await File(targetPath).writeAsBytes(data); - debugPrint('[RetroArchSaveStrategy] restored $filename to $targetPath'); return true; } catch (e) { - debugPrint('[RetroArchSaveStrategy] restoreSave error: $e'); return false; } } diff --git a/lib/core/save/strategies/rpcs3_save_strategy.dart b/lib/core/save/strategies/rpcs3_save_strategy.dart index 4a8b721..4e22b1e 100644 --- a/lib/core/save/strategies/rpcs3_save_strategy.dart +++ b/lib/core/save/strategies/rpcs3_save_strategy.dart @@ -48,7 +48,6 @@ class Rpcs3SaveStrategy extends SaveStrategy { return folderName.startsWith(titleId); }).toList(); if (byTitleId.isNotEmpty) { - debugPrint('[Rpcs3SaveStrategy] matched by title ID $titleId'); return byTitleId; } } @@ -67,11 +66,9 @@ class Rpcs3SaveStrategy extends SaveStrategy { }).toList(); if (byName.isNotEmpty) { - debugPrint('[Rpcs3SaveStrategy] matched by name: ${byName.map((d) => d.path.split('\\').last).toList()}'); return byName; } - debugPrint('[Rpcs3SaveStrategy] no save dirs found for ${game.name} in $saveRoot'); return []; } @@ -91,7 +88,6 @@ class Rpcs3SaveStrategy extends SaveStrategy { final saveDirs = await _findSaveDirs(saveRoot, game); if (saveDirs.isEmpty) { - debugPrint('[Rpcs3SaveStrategy] no save dirs found for ${game.name}'); return []; } @@ -119,7 +115,6 @@ class Rpcs3SaveStrategy extends SaveStrategy { } encoder.close(); - debugPrint('[Rpcs3SaveStrategy] packaged saves to $zipPath'); return [File(zipPath)]; } @@ -143,7 +138,6 @@ class Rpcs3SaveStrategy extends SaveStrategy { await Directory(entryPath).create(recursive: true); } } - debugPrint('[Rpcs3SaveStrategy] extracted $filename to $saveRoot'); return true; } @@ -153,10 +147,8 @@ class Rpcs3SaveStrategy extends SaveStrategy { final targetPath = '$saveDir\\$filename'; await backupSave(targetPath); await File(targetPath).writeAsBytes(data); - debugPrint('[Rpcs3SaveStrategy] restored $filename to $targetPath'); return true; } catch (e) { - debugPrint('[Rpcs3SaveStrategy] restoreSave error: $e'); return false; } } diff --git a/lib/core/save/strategies/windows_save_strategy.dart b/lib/core/save/strategies/windows_save_strategy.dart index 68dfc3c..d2ebe2c 100644 --- a/lib/core/save/strategies/windows_save_strategy.dart +++ b/lib/core/save/strategies/windows_save_strategy.dart @@ -50,11 +50,10 @@ class WindowsSaveStrategy extends SaveStrategy { try { final locations = await _wikiService.getSaveLocations(game.name); if (locations.isNotEmpty) { - debugPrint('[WindowsSaveStrategy] wiki found ${locations.length} locations for ${game.name}'); return locations.first['path']; } } catch (e) { - debugPrint('[WindowsSaveStrategy] wiki lookup failed: $e'); + // } return null; @@ -88,7 +87,6 @@ class WindowsSaveStrategy extends SaveStrategy { await encoder.addDirectory(dir); encoder.close(); - debugPrint('[WindowsSaveStrategy] packaged saves to $zipPath'); return [File(zipPath)]; } @@ -117,7 +115,6 @@ class WindowsSaveStrategy extends SaveStrategy { await Directory(entryPath).create(recursive: true); } } - debugPrint('[WindowsSaveStrategy] extracted $filename to $saveDir'); return true; } @@ -125,10 +122,8 @@ class WindowsSaveStrategy extends SaveStrategy { final targetPath = '$saveDir/$filename'; await backupSave(targetPath); await File(targetPath).writeAsBytes(data); - debugPrint('[WindowsSaveStrategy] restored $filename to $targetPath'); return true; } catch (e) { - debugPrint('[WindowsSaveStrategy] restoreSave error: $e'); rethrow; } } diff --git a/lib/core/save/strategies/xenia_save_strategy.dart b/lib/core/save/strategies/xenia_save_strategy.dart index 7958407..b111f98 100644 --- a/lib/core/save/strategies/xenia_save_strategy.dart +++ b/lib/core/save/strategies/xenia_save_strategy.dart @@ -66,7 +66,6 @@ class XeniaSaveStrategy extends SaveStrategy { await encoder.addDirectory(dir); encoder.close(); - debugPrint('[XeniaSaveStrategy] packaged saves to $zipPath'); return [File(zipPath)]; } @@ -92,17 +91,14 @@ class XeniaSaveStrategy extends SaveStrategy { await Directory(entryPath).create(recursive: true); } } - debugPrint('[XeniaSaveStrategy] extracted $filename to $saveDir'); return true; } final targetPath = '$saveDir\\$filename'; await backupSave(targetPath); await File(targetPath).writeAsBytes(data); - debugPrint('[XeniaSaveStrategy] restored $filename to $targetPath'); return true; } catch (e) { - debugPrint('[XeniaSaveStrategy] restoreSave error: $e'); return false; } } diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart index 2ee7855..90f3fa9 100644 --- a/lib/core/storage/directory_service.dart +++ b/lib/core/storage/directory_service.dart @@ -149,7 +149,6 @@ class DirectoryService { final result = await Process.run('cmd', ['/c', 'echo %APPDATA%'], runInShell: false); appData = result.stdout.toString().trim(); } catch (e) { - debugPrint('[DirectoryService] failed to get APPDATA: $e'); return null; } if (appData.isEmpty || appData.contains('%APPDATA%')) return null; @@ -167,10 +166,8 @@ class DirectoryService { await dest.parent.create(recursive: true); final byteData = await rootBundle.load('thirdparty/7zr.exe'); await dest.writeAsBytes(byteData.buffer.asUint8List()); - debugPrint('[DirectoryService] 7zr.exe extracted to ${dest.path}'); return dest.path; } catch (e) { - debugPrint('[DirectoryService] failed to extract 7zr.exe: $e'); return null; } } diff --git a/lib/core/windows/pcgamingwiki_service.dart b/lib/core/windows/pcgamingwiki_service.dart index bb74ec2..8ad3eb9 100644 --- a/lib/core/windows/pcgamingwiki_service.dart +++ b/lib/core/windows/pcgamingwiki_service.dart @@ -1,5 +1,4 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; import 'dart:io'; class PcGamingWikiService { @@ -39,7 +38,7 @@ class PcGamingWikiService { if (results.isNotEmpty) return results.first['title'] as String; } } catch (e) { - debugPrint('[PcGamingWiki] findPageTitle error: $e'); + // } return null; } @@ -57,7 +56,7 @@ class PcGamingWikiService { return response.data['parse']['wikitext']['*'] as String?; } } catch (e) { - debugPrint('[PcGamingWiki] getWikitext error: $e'); + // } return null; } @@ -73,7 +72,6 @@ class PcGamingWikiService { return _parseSaveLocations(wikitext, gameTitle, gameDir); } catch (e) { - debugPrint('[PcGamingWiki] getSaveLocations error: $e'); return []; } } @@ -111,7 +109,7 @@ class PcGamingWikiService { }); } } catch (e) { - debugPrint('[PcGamingWiki] parse error on line: $e'); + // } } return results; diff --git a/lib/core/windows/windows_game_service.dart b/lib/core/windows/windows_game_service.dart index db76fb2..e212723 100644 --- a/lib/core/windows/windows_game_service.dart +++ b/lib/core/windows/windows_game_service.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; class WindowsGameService { @@ -29,7 +28,6 @@ class WindowsGameService { .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9]'), ''); if (exeName.contains(hintLower) || hintLower.contains(exeName)) { - debugPrint('[WindowsGameService] matched exe by hint: ${exe.path}'); return exe.path; } } @@ -46,7 +44,6 @@ class WindowsGameService { } } - debugPrint('[WindowsGameService] largest exe: ${largest?.path}'); return largest?.path; } diff --git a/lib/main.dart b/lib/main.dart index 96328b3..4f4a952 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app.dart'; void main() { + WidgetsFlutterBinding.ensureInitialized(); runApp( const ProviderScope( child: FreegosyApp(), diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart index aac1605..7432bda 100644 --- a/lib/providers/library_provider.dart +++ b/lib/providers/library_provider.dart @@ -1,14 +1,152 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; import '../core/romm/romm_models.dart'; import 'romm_provider.dart'; +const String _gamesCacheKey = 'cached_games'; +const String _platformsCacheKey = 'cached_platforms'; +const String _gamesCacheTimeKey = 'cached_games_time'; +const String _platformsCacheTimeKey = 'cached_platforms_time'; +const int _cacheExpiryDays = 7; +const int _cacheMaxBytes = 10 * 1024 * 1024; // 10MB + +Future<bool> _isCacheValid(SharedPreferences prefs, String timeKey) async { + final savedTime = prefs.getString(timeKey); + if (savedTime == null) return false; + final cacheTime = DateTime.tryParse(savedTime); + if (cacheTime == null) return false; + return DateTime.now().difference(cacheTime).inDays < _cacheExpiryDays; +} + +Future<void> _saveGamesCache(List<Game> games) async { + final prefs = await SharedPreferences.getInstance(); + final jsonList = games.map((g) => { + 'id': g.id, + 'name': g.name, + 'platform_id': g.platformId, + 'platform_slug': g.platformSlug, + 'platform_display_name': g.platformDisplayName, + 'path_cover_large': g.pathCoverLarge, + 'path_cover_small': g.pathCoverSmall, + 'url_cover': g.urlCover, + 'url_download': g.fileUrl, + 'file_name': g.fileName, + 'fs_name': g.fsName, + 'file_size_bytes': g.fileSize, + 'multi_file_path': g.multiFilePath, + 'has_multiple_files': g.hasMultipleFiles, + }).toList(); + + final jsonString = jsonEncode(jsonList); + final sizeInBytes = jsonString.length; // Use .length for string size in bytes (UTF-8) + + if (sizeInBytes > _cacheMaxBytes) { + // Library too large to cache safely + // Store a flag so we know caching was skipped + await prefs.setBool('cache_size_exceeded', true); + await prefs.remove(_gamesCacheKey); + await prefs.remove(_gamesCacheTimeKey); + return; + } + + await prefs.setBool('cache_size_exceeded', false); + await prefs.setString(_gamesCacheKey, jsonString); + await prefs.setString( + _gamesCacheTimeKey, + DateTime.now().toIso8601String(), + ); +} + +Future<void> _savePlatformsCache(List<Platform> platforms) async { + final prefs = await SharedPreferences.getInstance(); + final jsonList = platforms.map((p) => { + 'id': p.id, + 'name': p.name, + 'slug': p.slug, + }).toList(); + await prefs.setString(_platformsCacheKey, jsonEncode(jsonList)); + await prefs.setString(_platformsCacheTimeKey, DateTime.now().toIso8601String()); +} + +Future<List<Game>?> _loadGamesCache() async { + final prefs = await SharedPreferences.getInstance(); + + // If cache was previously skipped due to size, + // don't attempt to load + final sizeExceeded = + prefs.getBool('cache_size_exceeded') ?? false; + if (sizeExceeded) return null; + + final isValid = await _isCacheValid(prefs, _gamesCacheTimeKey); + if (!isValid) return null; + + final jsonString = prefs.getString(_gamesCacheKey); + if (jsonString == null) return null; + + try { + final jsonList = jsonDecode(jsonString) as List<dynamic>; + return jsonList + .map((item) => Game.fromJson(item as Map<String, dynamic>)) + .toList(); + } catch (e) { + return null; + } +} + +Future<List<Platform>?> _loadPlatformsCache() async { + final prefs = await SharedPreferences.getInstance(); + final isValid = await _isCacheValid(prefs, _platformsCacheTimeKey); + if (!isValid) return null; + final jsonString = prefs.getString(_platformsCacheKey); + if (jsonString == null) return null; + try { + final jsonList = jsonDecode(jsonString) as List<dynamic>; + return jsonList + .map((item) => Platform.fromJson(item as Map<String, dynamic>)) + .toList(); + } catch (e) { + return null; + } +} + +const Map<String, Map<String, dynamic>> kDisplayPresets = { + 'windows_best': { + 'columnCount': 5, + 'cardAspectRatio': 0.72, + 'cardSpacing': 8.0, + 'showTitle': true, + 'showButtonsOnHover': false, + }, + 'steamdeck_best': { + 'columnCount': 3, + 'cardAspectRatio': 0.72, + 'cardSpacing': 12.0, + 'showTitle': true, + 'showButtonsOnHover': false, + }, + 'cozy': { + 'columnCount': 4, + 'cardAspectRatio': 0.72, + 'cardSpacing': 8.0, + 'showTitle': true, + 'showButtonsOnHover': false, + }, + 'compact': { + 'columnCount': 7, + 'cardAspectRatio': 1.0, + 'cardSpacing': 4.0, + 'showTitle': false, + 'showButtonsOnHover': true, + }, +}; + final searchQueryProvider = StateProvider<String>((ref) => ''); final selectedPlatformIdProvider = StateProvider<int?>((ref) => null); final cardAspectRatioProvider = StateProvider<double>((ref) { // Synchronous init — actual persisted value is loaded in _loadCardAspectRatio - // and set via the notifier. Default is 0.72 (square). + // and set via the notifier. Default is 0.72 (portrait). return 0.72; }); @@ -21,16 +159,121 @@ final cardAspectRatioLoaderProvider = FutureProvider<void>((ref) async { } }); +// Display Settings Providers +final columnCountProvider = StateProvider<int>((ref) { + return 4; +}); + +final columnCountLoaderProvider = FutureProvider<void>((ref) async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getInt('column_count'); + if (saved != null) { + ref.read(columnCountProvider.notifier).state = saved; + } +}); + +final cardSpacingProvider = StateProvider<double>((ref) { + return 8.0; +}); + +final cardSpacingLoaderProvider = FutureProvider<void>((ref) async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getDouble('card_spacing'); + if (saved != null) { + ref.read(cardSpacingProvider.notifier).state = saved; + } +}); + +final showTitleProvider = StateProvider<bool>((ref) { + return true; +}); + +final showTitleLoaderProvider = FutureProvider<void>((ref) async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getBool('show_title'); + if (saved != null) { + ref.read(showTitleProvider.notifier).state = saved; + } +}); + +final showButtonsOnHoverProvider = StateProvider<bool>((ref) { + return false; +}); + +final showButtonsOnHoverLoaderProvider = FutureProvider<void>((ref) async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getBool('show_buttons_on_hover'); + if (saved != null) { + ref.read(showButtonsOnHoverProvider.notifier).state = saved; + } +}); + +final activePresetProvider = StateProvider<String>((ref) { + return 'custom'; +}); + +final activePresetLoaderProvider = FutureProvider<void>((ref) async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString('active_preset'); + if (saved != null) { + ref.read(activePresetProvider.notifier).state = saved; + } +}); + final platformsProvider = FutureProvider<List<Platform>>((ref) async { final service = ref.watch(rommServiceProvider); if (service == null) return []; - return await service.getPlatforms(); + + // Try cache first + final cached = await _loadPlatformsCache(); + if (cached != null) { + // Refresh in background + Future.microtask(() async { + try { + final fresh = await service.getPlatforms(); + if (fresh.isNotEmpty) { + await _savePlatformsCache(fresh); + ref.invalidateSelf(); + } + } catch (_) {} + }); + return cached; + } + + // No valid cache — fetch fresh + final platforms = await service.getPlatforms(); + if (platforms.isNotEmpty) { + await _savePlatformsCache(platforms); + } + return platforms; }); final allGamesProvider = FutureProvider<List<Game>>((ref) async { final service = ref.watch(rommServiceProvider); if (service == null) return []; - return await service.getAllGames(); + + // Try cache first — return instantly if valid + final cached = await _loadGamesCache(); + if (cached != null) { + // Refresh in background without blocking UI + Future.microtask(() async { + try { + final fresh = await service.getAllGames(); + if (fresh.isNotEmpty) { + await _saveGamesCache(fresh); + ref.invalidateSelf(); + } + } catch (_) {} + }); + return cached; + } + + // No valid cache — fetch fresh and cache result + final games = await service.getAllGames(); + if (games.isNotEmpty) { + await _saveGamesCache(games); + } + return games; }); final filteredGamesProvider = Provider<List<Game>>((ref) { @@ -45,11 +288,14 @@ final filteredGamesProvider = Provider<List<Game>>((ref) { if (searchQuery.isNotEmpty) { filtered = filtered - .where((g) => g.name.toLowerCase().contains(searchQuery.toLowerCase())) + .where((g) => + g.displayName.toLowerCase().contains(searchQuery.toLowerCase()) || + g.name.toLowerCase().contains(searchQuery.toLowerCase())) .toList(); } - filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + filtered.sort((a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); return filtered; }); @@ -59,4 +305,4 @@ final retroarchSyncModeLoaderProvider = FutureProvider<void>((ref) async { final prefs = await SharedPreferences.getInstance(); final saved = prefs.getString('retroarch_sync_mode') ?? 'both'; ref.read(retroarchSyncModeProvider.notifier).state = saved; -}); \ No newline at end of file +}); diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart index 989865d..ff962b6 100644 --- a/lib/providers/romm_provider.dart +++ b/lib/providers/romm_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:freegosy/core/storage/directory_service.dart'; @@ -17,7 +16,6 @@ final rommConfigProvider = FutureProvider<RomMConfig>((ref) async { final password = prefs.getString('rommPassword') ?? ''; final token = prefs.getString('rommAuthToken'); - debugPrint('[rommConfigProvider] loaded baseUrl=$baseUrl user=$username passLen=${password.length} hasToken=${token != null && token.isNotEmpty}'); return RomMConfig(baseUrl: baseUrl, username: username, password: password, token: token); }); diff --git a/lib/ui/screens/download_screen.dart b/lib/ui/screens/download_screen.dart index af3eb13..d46c258 100644 --- a/lib/ui/screens/download_screen.dart +++ b/lib/ui/screens/download_screen.dart @@ -12,22 +12,24 @@ class DownloadScreen extends ConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Downloads')), - body: downloads.isEmpty - ? const Center(child: Text('No active downloads')) - : ListView.builder( - itemCount: downloads.length, - itemBuilder: (context, index) { - final gameId = downloads.keys.elementAt(index); - final progress = downloads[gameId]!; - return DownloadProgressCard( - gameName: progress.gameName, - progress: progress, - onCancel: () { - ref.read(downloadProvider.notifier).removeDownload(gameId); - }, - ); - }, - ), + body: ExcludeSemantics( + child: downloads.isEmpty + ? const Center(child: Text('No active downloads')) + : ListView.builder( + itemCount: downloads.length, + itemBuilder: (context, index) { + final gameId = downloads.keys.elementAt(index); + final progress = downloads[gameId]!; + return DownloadProgressCard( + gameName: progress.gameName, + progress: progress, + onCancel: () { + ref.read(downloadProvider.notifier).removeDownload(gameId); + }, + ); + }, + ), + ), ); } } diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index 70c0949..e4379e0 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.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 '../../core/storage/directory_service.dart'; import '../../core/romm/romm_models.dart'; import '../widgets/game_card.dart'; import '../widgets/platform_filter_bar.dart'; @@ -10,16 +12,56 @@ import '../widgets/windows_game_config_dialog.dart'; import '../../core/emulator/strategies/windows_strategy.dart'; import 'dart:convert'; -class LibraryScreen extends ConsumerWidget { +class LibraryScreen extends ConsumerStatefulWidget { const LibraryScreen({super.key}); + @override + ConsumerState<LibraryScreen> createState() => _LibraryScreenState(); +} + +class _LibraryScreenState extends ConsumerState<LibraryScreen> { + Map<String, bool> _downloadedStates = {}; + bool _downloadStatesLoaded = false; + + 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; + }); + } + } + + Future<void> _refreshDownloadState( + DirectoryService dirService, Game game) async { + final isDownloaded = await dirService.isRomDownloaded(game); + if (mounted) { + setState(() { + _downloadedStates[game.id] = isDownloaded; + }); + } + } + Future<void> _handleLaunch(BuildContext context, WidgetRef ref, game) async { final registry = ref.read(strategyRegistryProvider); final strategy = registry?.getStrategyForSlug(game.platformSlug ?? ''); if (strategy == null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('No emulator configured for ${game.platformDisplayName ?? game.platformSlug ?? 'this platform'}')), + SnackBar( + content: Text( + 'No emulator configured for ${game.platformDisplayName ?? game.platformSlug ?? 'this platform'}')), ); return; } @@ -48,7 +90,8 @@ class LibraryScreen extends ConsumerWidget { children: [ Text('${game.name} is not downloaded yet.'), const SizedBox(height: 8), - const Text('Expected location:', style: TextStyle(fontWeight: FontWeight.bold)), + const Text('Expected location:', + style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 4), SelectableText( expectedRomPath, @@ -95,7 +138,9 @@ class LibraryScreen extends ConsumerWidget { if (!context.mounted) return; if (pulled) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cloud save restored'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('Cloud save restored'), + duration: Duration(seconds: 2)), ); } } catch (e) { @@ -104,7 +149,8 @@ class LibraryScreen extends ConsumerWidget { context: context, builder: (ctx) => AlertDialog( title: const Text('Save Sync Warning'), - content: Text('${e.toString().replaceAll('Exception: ', '')}\n\nYou can still play, but your cloud save will not be restored. After playing once, exit the game and sync saves manually.'), + content: Text( + '${e.toString().replaceAll('Exception: ', '')}\n\nYou can still play, but your cloud save will not be restored. After playing once, exit the game and sync saves manually.'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), @@ -126,8 +172,10 @@ class LibraryScreen extends ConsumerWidget { await strategy.launch(game, existingRomPath); } catch (e) { if (!context.mounted) return; - final isWindows = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); - final isMissingExe = e.toString().contains('No executable') || e.toString().contains('not found'); + final isWindows = + ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); + final isMissingExe = + e.toString().contains('No executable') || e.toString().contains('not found'); if (isWindows && isMissingExe) { await _handleWindowsConfig(context, ref, game); } else { @@ -135,15 +183,17 @@ class LibraryScreen extends ConsumerWidget { SnackBar( content: Text('Launch failed: $e'), duration: const Duration(seconds: 8), - ), + ), ); } } } - Future<void> _handleWindowsConfig(BuildContext context, WidgetRef ref, Game game) async { + Future<void> _handleWindowsConfig( + BuildContext context, WidgetRef ref, Game game) async { final registry = ref.read(strategyRegistryProvider); - final windowsStrategy = registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?; + final windowsStrategy = + registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?; final syncService = ref.read(saveSyncServiceProvider); final result = await showDialog<Map<String, String>>( @@ -151,7 +201,8 @@ class LibraryScreen extends ConsumerWidget { builder: (ctx) => WindowsGameConfigDialog( game: game, currentExePath: windowsStrategy?.getExeOverride(game.id), - currentSavePath: syncService?.windowsSaveStrategy.getManualOverride(game.id), + currentSavePath: + syncService?.windowsSaveStrategy.getManualOverride(game.id), ), ); if (result == null) return; // user cancelled @@ -159,20 +210,23 @@ class LibraryScreen extends ConsumerWidget { final exe = result['exe'] ?? ''; final save = result['save'] ?? ''; - if (exe.isNotEmpty) await windowsStrategy?.setExeOverride(game.id, exe); - if (save.isNotEmpty) await syncService?.windowsSaveStrategy.setManualOverride(game.id, save); + if (exe.isNotEmpty) { + await windowsStrategy?.setExeOverride(game.id, exe); + } + if (save.isNotEmpty) { + await syncService?.windowsSaveStrategy.setManualOverride(game.id, save); + } if (!context.mounted) return; - debugPrint('[Launch] game=${game.name} slug=${game.platformSlug} fsName=${game.fsName} fileName=${game.fileName}'); await _handleLaunch(context, ref, game); } - Future<void> _handleSyncSaves(BuildContext context, WidgetRef ref, Game game) async { + Future<void> _handleSyncSaves( + BuildContext context, WidgetRef ref, Game game) async { final syncService = ref.read(saveSyncServiceProvider); if (syncService == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Save sync not available')), + const SnackBar(content: Text('Save sync not available')), ); return; } @@ -206,22 +260,66 @@ class LibraryScreen extends ConsumerWidget { return; } final url = service.getDownloadUrl(game); - final basicAuth = 'Basic ${base64Encode(utf8.encode('${service.config.username}:${service.config.password}'))}'; + final basicAuth = + 'Basic ${base64Encode(utf8.encode('${service.config.username}:${service.config.password}'))}'; final headers = <String, String>{'Authorization': basicAuth}; - ref.read(downloadProvider.notifier).startDownload(game, url, headers: headers); + ref + .read(downloadProvider.notifier) + .startDownload(game, url, headers: headers); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Downloading ${game.name}...')), ); + + final dirService = ref.read(directoryServiceProvider).asData?.value; + if (dirService != null) { + Future.delayed(const Duration(seconds: 2), () { + _refreshDownloadState(dirService, game); + }); + } + } + + double _calculateCardHeight(int columnCount, double cardSpacing, + double cardAspectRatio, BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + const padding = 24.0; + final totalSpacing = cardSpacing * (columnCount - 1); + final cardWidth = (screenWidth - padding - totalSpacing) / columnCount; + final safeRatio = cardAspectRatio <= 0 ? 0.56 : cardAspectRatio; + final coverHeight = cardWidth / safeRatio; + final totalHeight = coverHeight + 90.0; + return totalHeight.clamp(100.0, 900.0); + } + + Widget _buildSkeletonGrid( + double cardAspectRatio, int columnCount, double cardSpacing) { + return GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + crossAxisSpacing: cardSpacing, + mainAxisSpacing: cardSpacing, + mainAxisExtent: _calculateCardHeight( + columnCount, cardSpacing, cardAspectRatio, context), + ), + itemCount: 20, + itemBuilder: (context, index) { + return _SkeletonCard(); + }, + ); } @override - Widget build(BuildContext context, WidgetRef ref) { + 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 cardAspectRatio = ref.watch(cardAspectRatioProvider); + final columnCount = ref.watch(columnCountProvider); + final cardSpacing = ref.watch(cardSpacingProvider); + final showTitle = ref.watch(showTitleProvider); + final showButtonsOnHover = ref.watch(showButtonsOnHoverProvider); final rommConfigAsync = ref.watch(rommConfigProvider); final directoryServiceAsync = ref.watch(directoryServiceProvider); @@ -240,8 +338,9 @@ class LibraryScreen extends ConsumerWidget { return Scaffold( appBar: AppBar(title: Text(appBarTitle)), - body: Column( - children: [ + body: ExcludeSemantics( + child: Column( + children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: TextField( @@ -270,7 +369,8 @@ class LibraryScreen extends ConsumerWidget { ), Expanded( child: gamesAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => + _buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing), error: (e, s) => Center( child: Padding( padding: const EdgeInsets.all(16.0), @@ -283,14 +383,21 @@ class LibraryScreen extends ConsumerWidget { ), data: (_) { final gamesCount = filteredGames.length; - final countDisplayText = (selectedPlatformId == null && searchQuery.isEmpty) - ? 'Showing all $gamesCount games' - : 'Showing $gamesCount games'; + 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: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0), child: Align( alignment: Alignment.centerRight, child: Text( @@ -300,49 +407,96 @@ class LibraryScreen extends ConsumerWidget { ), ), Expanded( - child: filteredGames.isEmpty - ? const Center(child: Text('No games found')) - : GridView.builder( - padding: const EdgeInsets.all(12), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - childAspectRatio: cardAspectRatio, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: filteredGames.length, - itemBuilder: (context, index) { - final game = filteredGames[index]; - final dirService = directoryServiceAsync.asData?.value; - final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? ''); - if (dirService == null) { - return GestureDetector( - onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null, - child: GameCard( - game: game, - onDownload: () => _startDownload(context, ref, game), - onLaunch: () => _handleLaunch(context, ref, game), - onSyncSaves: () => _handleSyncSaves(context, ref, game), - ), - ); - } - return FutureBuilder<bool>( - future: dirService.isRomDownloaded(game), - builder: (context, snapshot) { + 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'); // Added this line + 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')), + ), + ], + ) + : GridView.builder( + padding: const EdgeInsets.all(12), + 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() ?? ''); + if (dirService == null) { return GestureDetector( - onLongPress: isWindowsGame ? () => _handleWindowsConfig(context, ref, game) : null, + onLongPress: isWindowsGame + ? () => _handleWindowsConfig( + context, ref, game) + : null, child: GameCard( game: game, - isDownloaded: snapshot.data ?? false, - onDownload: () => _startDownload(context, ref, game), - onLaunch: () => _handleLaunch(context, ref, game), - onSyncSaves: () => _handleSyncSaves(context, ref, game), + 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, + isDownloaded: _downloadedStates[game.id] ?? false, + showTitle: showTitle, + showButtonsOnHover: showButtonsOnHover, + onDownload: () => _startDownload( + context, ref, game), + onLaunch: () => + _handleLaunch(context, ref, game), + onSyncSaves: () => _handleSyncSaves( + context, ref, game), + ), + ); + }, + ), + ), ), ], ); @@ -351,6 +505,56 @@ class LibraryScreen extends ConsumerWidget { ), ], ), + ), + ); + } +} + +class _SkeletonCard extends StatefulWidget { + @override + State<_SkeletonCard> createState() => _SkeletonCardState(); +} + +class _SkeletonCardState extends State<_SkeletonCard> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color.lerp( + const Color(0xFF1a1a1a), + const Color(0xFF2a2a2a), + _animation.value, + ), + ), + ); + }, ); } } diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index 7eefcb2..55c92dd 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -23,6 +23,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { late TextEditingController _usernameController; late TextEditingController _passwordController; bool _isSaving = false; + Map<String, bool> _emulatorInstallStates = {}; + bool _emulatorsLoaded = false; @override void initState() { @@ -40,12 +42,39 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { super.dispose(); } + Future<void> _loadEmulatorStates(DirectoryService directoryService) async { + if (_emulatorsLoaded) return; + final states = <String, bool>{}; + for (final def in kEmulatorDefinitions) { + final id = def['id'] as String; + final exe = def['windows_executable'] as String; + if (exe.isEmpty) { + states[id] = true; + continue; + } + states[id] = await directoryService.isEmulatorInstalled(id, exe); + } + if (mounted) { + setState(() { + _emulatorInstallStates = states; + _emulatorsLoaded = true; + }); + } + } + @override Widget build(BuildContext context) { final directoryServiceAsync = ref.watch(directoryServiceProvider); final rommService = ref.watch(rommServiceProvider); final rommConfigAsync = ref.watch(rommConfigProvider); + final cardAspectRatio = ref.watch(cardAspectRatioProvider); + final columnCount = ref.watch(columnCountProvider); + final cardSpacing = ref.watch(cardSpacingProvider); + final showTitle = ref.watch(showTitleProvider); + final showButtonsOnHover = ref.watch(showButtonsOnHoverProvider); + final activePreset = ref.watch(activePresetProvider); + return Scaffold( appBar: AppBar(title: const Text('Settings')), body: rommConfigAsync.when( @@ -62,19 +91,32 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { if (rommService == null) { return const Center(child: CircularProgressIndicator()); } - return ListView( - padding: const EdgeInsets.all(16.0), - children: [ - _buildRommServerSection(context, ref, rommService), - const SizedBox(height: 24), - _buildCardAspectRatioSection(context, ref), - const SizedBox(height: 24), - _buildStorageSection(directoryService), - const SizedBox(height: 24), - _buildRetroArchSyncModeSection(context, ref), - const SizedBox(height: 24), - _buildEmulatorsSection(directoryService), - ], + + _loadEmulatorStates(directoryService); + + return ExcludeSemantics( + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildRommServerSection(context, ref, rommService), + const SizedBox(height: 24), + _buildDisplaySection( + context, + cardAspectRatio, + columnCount, + cardSpacing, + showTitle, + showButtonsOnHover, + activePreset, + ), + const SizedBox(height: 24), + _buildStorageSection(directoryService), + const SizedBox(height: 24), + _buildRetroArchSyncModeSection(context, ref), + const SizedBox(height: 24), + _buildEmulatorsSection(directoryService), + ], + ), ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -87,6 +129,161 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ); } + Widget _buildDisplaySection( + BuildContext context, + double cardAspectRatio, + int columnCount, + double cardSpacing, + bool showTitle, + bool showButtonsOnHover, + String activePreset, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Library Display', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + const Text('Presets', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + _presetChip('Windows', 'windows_best', activePreset), + _presetChip('Steam Deck', 'steamdeck_best', activePreset), + _presetChip('Cozy', 'cozy', activePreset), + _presetChip('Compact', 'compact', activePreset), + _presetChip('Custom', 'custom', activePreset), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Columns per row', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + Text('$columnCount', style: const TextStyle(fontSize: 16, color: Colors.deepPurple)), + ], + ), + Slider( + value: columnCount.toDouble(), + min: 2, + max: 8, + divisions: 6, + label: '$columnCount', + onChanged: (value) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(columnCountProvider.notifier).state = value.toInt(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('column_count', value.toInt()); + await prefs.setString('active_preset', 'custom'); + }, + ), + const SizedBox(height: 16), + const Text('Card Shape', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + SegmentedButton<double>( + segments: const [ + ButtonSegment(value: 1.0, label: Text('Square')), + ButtonSegment(value: 0.72, label: Text('Portrait')), + ButtonSegment(value: 0.58, label: Text('Tall')), + ], + selected: { + [1.0, 0.72, 0.58].reduce((a, b) => + (a - cardAspectRatio).abs() < (b - cardAspectRatio).abs() + ? a + : b) + }, + onSelectionChanged: (selection) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(cardAspectRatioProvider.notifier).state = selection.first; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('card_aspect_ratio', selection.first); + await prefs.setString('active_preset', 'custom'); + }, + ), + const SizedBox(height: 16), + const Text('Card Spacing', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + SegmentedButton<double>( + segments: const [ + ButtonSegment(value: 4.0, label: Text('Tight')), + ButtonSegment(value: 8.0, label: Text('Normal')), + ButtonSegment(value: 12.0, label: Text('Airy')), + ], + selected: { + [4.0, 8.0, 12.0].reduce((a, b) => + (a - cardSpacing).abs() < (b - cardSpacing).abs() ? a : b) + }, + onSelectionChanged: (selection) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(cardSpacingProvider.notifier).state = selection.first; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('card_spacing', selection.first); + await prefs.setString('active_preset', 'custom'); + }, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Show game title'), + subtitle: const Text('Display title text below cover art'), + value: showTitle, + contentPadding: EdgeInsets.zero, + onChanged: (value) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(showTitleProvider.notifier).state = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('show_title', value); + await prefs.setString('active_preset', 'custom'); + }, + ), + SwitchListTile( + title: const Text('Show buttons on hover only'), + subtitle: const Text('Buttons appear when hovering over a card'), + value: showButtonsOnHover, + contentPadding: EdgeInsets.zero, + onChanged: (value) async { + ref.read(activePresetProvider.notifier).state = 'custom'; + ref.read(showButtonsOnHoverProvider.notifier).state = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('show_buttons_on_hover', value); + await prefs.setString('active_preset', 'custom'); + }, + ), + ], + ); + } + + Widget _presetChip(String label, String presetKey, String activePreset) { + final isSelected = activePreset == presetKey; + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) async { + if (!selected) return; + ref.read(activePresetProvider.notifier).state = presetKey; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('active_preset', presetKey); + if (presetKey == 'custom') return; + final preset = kDisplayPresets[presetKey]; + if (preset == null) return; + final cols = preset['columnCount'] as int; + final ratio = preset['cardAspectRatio'] as double; + final spacing = preset['cardSpacing'] as double; + final title = preset['showTitle'] as bool; + final hover = preset['showButtonsOnHover'] as bool; + ref.read(columnCountProvider.notifier).state = cols; + ref.read(cardAspectRatioProvider.notifier).state = ratio; + ref.read(cardSpacingProvider.notifier).state = spacing; + ref.read(showTitleProvider.notifier).state = title; + ref.read(showButtonsOnHoverProvider.notifier).state = hover; + await prefs.setInt('column_count', cols); + await prefs.setDouble('card_aspect_ratio', ratio); + await prefs.setDouble('card_spacing', spacing); + await prefs.setBool('show_title', title); + await prefs.setBool('show_buttons_on_hover', hover); + }, + ); + } + Widget _buildRetroArchSyncModeSection(BuildContext context, WidgetRef ref) { final syncMode = ref.watch(retroarchSyncModeProvider); return Column( @@ -191,16 +388,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { // it over HTTP or at all, fall back to Basic auth silently. try { await RommService.fetchToken(baseUrl, username, password); - debugPrint('[Settings] fetchToken succeeded'); } catch (e) { - debugPrint('[Settings] fetchToken failed ($e), falling back to Basic auth'); // Clear any stale token so Basic auth is used instead. final p = await SharedPreferences.getInstance(); await p.remove('rommAuthToken'); } // Save credentials regardless of whether token fetch succeeded. - debugPrint('[Settings] saving baseUrl=$baseUrl user=$username passLen=${password.length}'); final prefs = await SharedPreferences.getInstance(); await prefs.setString('rommBaseUrl', baseUrl); await prefs.setString('rommUsername', username); @@ -228,32 +422,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ); } - Widget _buildCardAspectRatioSection(BuildContext context, WidgetRef ref) { - final cardAspectRatio = ref.watch(cardAspectRatioProvider); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Library Display', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - const Text('Card Aspect Ratio', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), - const SizedBox(height: 8), - SegmentedButton<double>( - segments: const [ - ButtonSegment(value: 0.72, label: Text('Square')), - ButtonSegment(value: 0.56, label: Text('Portrait')), - ], - selected: {cardAspectRatio}, - onSelectionChanged: (selection) async { - final value = selection.first; - ref.read(cardAspectRatioProvider.notifier).state = value; - final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble('card_aspect_ratio', value); - }, - ), - ], - ); - } - Widget _buildStorageSection(DirectoryService directoryService) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -317,78 +485,81 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { } Widget _buildEmulatorsSection(DirectoryService directoryService) { - final emulatorDownloadService = EmulatorDownloadService(Dio(), directoryService); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Emulators', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const Text('Emulators', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), - ...kEmulatorDefinitions.map<Widget>((def) { - final emulatorId = def['id'] as String; - final emulatorName = def['name'] as String; - final windowsExecutable = def['windows_executable'] as String; - - return FutureBuilder<bool>( - future: directoryService.isEmulatorInstalled(emulatorId, windowsExecutable), - builder: (context, snapshot) { - final isInstalled = snapshot.data ?? false; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - children: [ - Icon( - isInstalled ? Icons.check_circle : Icons.cancel, - color: isInstalled ? Colors.green : Colors.red, - ), - const SizedBox(width: 8), - Expanded(child: Text(emulatorName)), - ElevatedButton( - onPressed: isInstalled - ? null - : () async { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Starting download for $emulatorName...')), - ); - } - try { - await for (var progress in emulatorDownloadService.downloadEmulator(emulatorId)) { - if (progress.error != null) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error downloading $emulatorName: ${progress.error}')), - ); - } - break; - } - if (progress.isComplete) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$emulatorName downloaded and extracted.')), - ); - } - // ignore: unused_result - ref.refresh(directoryServiceProvider); - break; + if (!_emulatorsLoaded) + const Center(child: CircularProgressIndicator()) + else + ...kEmulatorDefinitions.map<Widget>((def) { + final emulatorId = def['id'] as String; + final emulatorName = def['name'] as String; + final isInstalled = _emulatorInstallStates[emulatorId] ?? false; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon( + isInstalled ? Icons.check_circle : Icons.cancel, + color: isInstalled ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Expanded(child: Text(emulatorName)), + ElevatedButton( + onPressed: isInstalled + ? null + : () async { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text('Starting download for $emulatorName...'), + )); + final emulatorDownloadService = + EmulatorDownloadService( + Dio(), directoryService); + try { + await for (final progress in emulatorDownloadService + .downloadEmulator(emulatorId)) { + if (progress.error != null) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text('Error: ${progress.error}'), + )); } + break; } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('An unexpected error occurred: $e')), - ); + if (progress.isComplete) { + if (mounted) { + setState(() { + _emulatorInstallStates[emulatorId] = true; + }); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: + Text('$emulatorName downloaded.'), + )); + } + break; } } - }, - child: Text(isInstalled ? 'Installed' : 'Download'), - ), - ], - ), - ); - }, - ); - }), + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar( + content: Text('Unexpected error: $e'), + )); + } + } + }, + child: Text(isInstalled ? 'Installed' : 'Download'), + ), + ], + ), + ); + }), ], ); } diff --git a/lib/ui/widgets/game_card.dart b/lib/ui/widgets/game_card.dart index a1966a5..e41b224 100644 --- a/lib/ui/widgets/game_card.dart +++ b/lib/ui/widgets/game_card.dart @@ -3,12 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/romm/romm_models.dart'; import '../../providers/romm_provider.dart'; -class GameCard extends ConsumerWidget { +class GameCard extends ConsumerStatefulWidget { final Game game; final VoidCallback onDownload; final VoidCallback onLaunch; final VoidCallback? onSyncSaves; final bool isDownloaded; + final bool showTitle; + final bool showButtonsOnHover; const GameCard({ super.key, @@ -17,109 +19,162 @@ class GameCard extends ConsumerWidget { required this.onLaunch, this.onSyncSaves, this.isDownloaded = false, + this.showTitle = true, + this.showButtonsOnHover = false, }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<GameCard> createState() => _GameCardState(); +} + +class _GameCardState extends ConsumerState<GameCard> { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { final service = ref.watch(rommServiceProvider); - final finalCoverUrl = service?.resolveCoverUrl(game); + final finalCoverUrl = service?.resolveCoverUrl(widget.game); - return Card( - elevation: 2, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Cover image - 75% height approximately - Expanded( - flex: 75, - child: Stack( - fit: StackFit.expand, - children: [ - (finalCoverUrl == null || finalCoverUrl.isEmpty) - ? const Center(child: Icon(Icons.sports_esports, size: 48)) - : Image.network( - finalCoverUrl, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => const Center( - child: Icon(Icons.sports_esports, size: 48), - ), - ), - if (isDownloaded) - Positioned( - top: 4, - left: 4, - child: Container( - width: 22, - height: 22, - decoration: const BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - ), - child: const Icon(Icons.check, size: 14, color: Colors.white), - ), - ), - ], - ), - ), - // Content - 25% height approximately - Expanded( - flex: 25, + return RepaintBoundary( + child: GestureDetector( + onLongPress: () => _showContextMenu(context), + child: MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: Card( + elevation: 2, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0), - child: Text( - game.name, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + // Cover image - fills remaining space + Expanded( + child: Stack( + fit: StackFit.expand, children: [ - IconButton( - visualDensity: VisualDensity.compact, - iconSize: 32, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.download), - onPressed: onDownload, - tooltip: 'Download', - ), - IconButton( - visualDensity: VisualDensity.compact, - iconSize: 32, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.play_arrow), - onPressed: onLaunch, - tooltip: 'Launch', - ), - if (onSyncSaves != null) - IconButton( - visualDensity: VisualDensity.compact, - iconSize: 24, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.cloud_upload), - onPressed: onSyncSaves, - tooltip: 'Sync saves', + (finalCoverUrl == null || finalCoverUrl.isEmpty) + ? const Center(child: Icon(Icons.sports_esports, size: 48)) + : Image.network( + finalCoverUrl, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + errorBuilder: (context, error, stackTrace) => const Center( + child: Icon(Icons.sports_esports, size: 48), + ), + ), + if (widget.isDownloaded) + Positioned( + top: 4, + left: 4, + child: Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: const Icon(Icons.check, size: 14, color: Colors.white), + ), ), ], ), ), + // Content - fixed height + SizedBox( + height: 90, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!widget.showButtonsOnHover || !_isHovering) + if (widget.showTitle) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 1.0), + child: Text( + widget.game.displayName, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + ), + ) + else + const Padding( + padding: EdgeInsets.symmetric(vertical: 1.0), + child: Center( + child: Icon(Icons.more_horiz, size: 16, color: Colors.grey), + ), + ), + + if (!widget.showButtonsOnHover || _isHovering) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 22, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.download), + onPressed: widget.onDownload, + tooltip: 'Download', + ), + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 22, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.play_arrow), + onPressed: widget.onLaunch, + tooltip: 'Launch', + ), + if (widget.onSyncSaves != null) + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: const Icon(Icons.cloud_upload), + onPressed: widget.onSyncSaves, + tooltip: 'Sync saves', + ), + ], + ), + ), + ], + ), + ), + ), ], ), ), - ], + ), ), ); } + + void _showContextMenu(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Context Menu'), + content: const Text('Context menu functionality will be implemented here.'), + actions: <Widget>[ + TextButton( + child: const Text('Close'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ], + ); + }, + ); + } } diff --git a/lib/ui/widgets/platform_filter_bar.dart b/lib/ui/widgets/platform_filter_bar.dart index dacb1c2..48c0fb5 100644 --- a/lib/ui/widgets/platform_filter_bar.dart +++ b/lib/ui/widgets/platform_filter_bar.dart @@ -29,7 +29,7 @@ class PlatformFilterBar extends StatelessWidget { ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( children: [ Padding( diff --git a/test/game_card_test.dart b/test/game_card_test.dart new file mode 100644 index 0000000..16510e3 --- /dev/null +++ b/test/game_card_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:freegosy/ui/widgets/game_card.dart'; +import 'package:freegosy/core/romm/romm_models.dart'; + +void main() { + testWidgets('GameCard should render without overflow', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: GameCard( + game: Game( + id: '1', + name: 'Test Game', + fileSize: 0, + ), + onDownload: () {}, + onLaunch: () {}, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(GameCard), findsOneWidget); + }); +}