-
-
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);
+ });
+}