-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 1198ca3
abduznik edited this page May 23, 2026
·
1 revision
Commit: 1198ca353777e746a6d69c96cceec67c21bef2b5
Author: Claude
Date: 2026-03-23
- romm_service: Replace Basic auth with Bearer token interceptor; add static fetchToken() that POSTs to /api/token and stores token in SharedPreferences. All API calls now use the Dio interceptor.
- romm_models: Add optional token field to RomMConfig.
- romm_provider: Load stored Bearer token in rommConfigProvider; add loginProvider exposing a login function; use ref.invalidate instead of ref.refresh.
- settings_screen: Save button now calls fetchToken() first — shows error on login failure and saves token on success. Add card aspect ratio toggle (Square 0.72 / Portrait 0.56) backed by SharedPreferences.
- game_card: Add isDownloaded parameter; show green checkmark badge on top-left of cover image when game is locally downloaded.
- library_screen: AppBar title shows "Freegosy • • N games". Downloads use Bearer token header instead of Basic auth. Each card uses FutureBuilder with directoryService.isRomDownloaded() to set the isDownloaded flag.
- library_provider: Persist cardAspectRatio to SharedPreferences (key 'card_aspect_ratio'); add cardAspectRatioLoaderProvider that rehydrates the value on startup.
- app.dart: Deep dark theme — scaffold #0f0f0f, surface/card #1a1a1a, accent Colors.deepPurple. Convert to ConsumerStatefulWidget to eagerly invoke cardAspectRatioLoaderProvider.
https://claude.ai/code/session_01HDTwbJh1qHLTNEKYXQm3U4
Why: Adds a new feature or capability to the application.
lib/app.dart | 54 +++++++++++++++--
lib/core/romm/romm_models.dart | 4 ++
lib/core/romm/romm_service.dart | 54 +++++++++++++----
lib/providers/library_provider.dart | 17 +++++-
lib/providers/romm_provider.dart | 37 ++++++------
lib/ui/screens/library_screen.dart | 51 +++++++++++-----
lib/ui/screens/settings_screen.dart | 114 ++++++++++++++++++++++++------------
lib/ui/widgets/game_card.dart | 35 ++++++++---
8 files changed, 271 insertions(+), 95 deletions(-)
lib/app.dartlib/core/romm/romm_models.dartlib/core/romm/romm_service.dartlib/providers/library_provider.dartlib/providers/romm_provider.dartlib/ui/screens/library_screen.dartlib/ui/screens/settings_screen.dartlib/ui/widgets/game_card.dart
diff --git a/lib/app.dart b/lib/app.dart
index d0ba6fe..f7a609d 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -1,16 +1,18 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'providers/library_provider.dart';
import 'ui/screens/library_screen.dart';
import 'ui/screens/download_screen.dart';
import 'ui/screens/settings_screen.dart';
-class FreegosyApp extends StatefulWidget {
+class FreegosyApp extends ConsumerStatefulWidget {
const FreegosyApp({super.key});
@override
- State<FreegosyApp> createState() => _FreegosyAppState();
+ ConsumerState<FreegosyApp> createState() => _FreegosyAppState();
}
-class _FreegosyAppState extends State<FreegosyApp> {
+class _FreegosyAppState extends ConsumerState<FreegosyApp> {
int _currentIndex = 0;
final List<Widget> _screens = const [
@@ -19,11 +21,55 @@ class _FreegosyAppState extends State<FreegosyApp> {
SettingsScreen(),
];
+ @override
+ void initState() {
+ super.initState();
+ // Eagerly load persisted card aspect ratio
+ ref.read(cardAspectRatioLoaderProvider);
+ }
+
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Freegosy',
- theme: ThemeData.dark(useMaterial3: true),
+ theme: ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.deepPurple,
+ brightness: Brightness.dark,
+ surface: const Color(0xFF1a1a1a),
+ ),
+ scaffoldBackgroundColor: const Color(0xFF0f0f0f),
+ cardTheme: const CardTheme(
+ color: Color(0xFF1a1a1a),
+ elevation: 2,
+ ),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Color(0xFF0f0f0f),
+ foregroundColor: Colors.white,
+ elevation: 0,
+ surfaceTintColor: Colors.transparent,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: const Color(0xFF1a1a1a),
+ indicatorColor: Colors.deepPurple.withOpacity(0.3),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: const Color(0xFF1a1a1a),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ borderSide: BorderSide(color: Colors.deepPurple.shade800),
+ ),
+ enabledBorderSide: BorderSide(color: Colors.deepPurple.shade900),
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.deepPurple,
+ foregroundColor: Colors.white,
+ ),
+ ),
+ ),
home: Scaffold(
body: _screens[_currentIndex],
bottomNavigationBar: NavigationBar(
diff --git a/lib/core/romm/romm_models.dart b/lib/core/romm/romm_models.dart
index 3cae557..f5091dd 100644
--- a/lib/core/romm/romm_models.dart
+++ b/lib/core/romm/romm_models.dart
@@ -89,11 +89,13 @@ class RomMConfig {
final String baseUrl;
final String username;
final String password;
+ final String? token;
RomMConfig({
required this.baseUrl,
required this.username,
required this.password,
+ this.token,
});
factory RomMConfig.fromJson(Map<String, dynamic> json) {
@@ -101,6 +103,7 @@ class RomMConfig {
baseUrl: json['baseUrl']?.toString() ?? '',
username: json['username']?.toString() ?? '',
password: json['password']?.toString() ?? '',
+ token: json['token']?.toString(),
);
}
@@ -109,6 +112,7 @@ class RomMConfig {
'baseUrl': baseUrl,
'username': username,
'password': password,
+ if (token != null) 'token': token,
};
}
}
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index a915f54..7f519c1 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -1,5 +1,5 @@
-import 'dart:convert';
import 'package:dio/dio.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'romm_models.dart';
class RommService {
@@ -11,15 +11,44 @@ class RommService {
baseUrl: config.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
- ));
+ )) {
+ _dio.interceptors.add(InterceptorsWrapper(
+ onRequest: (options, handler) {
+ final token = config.token;
+ if (token != null && token.isNotEmpty) {
+ options.headers['Authorization'] = 'Bearer $token';
+ }
+ handler.next(options);
+ },
+ ));
+ }
- Options get _authOptions {
- final basicAuth = 'Basic ${base64Encode(utf8.encode('${config.username}:${config.password}'))}';
- return Options(headers: {'authorization': basicAuth});
+ /// Calls /api/token with username/password, stores the Bearer token in SharedPreferences.
+ /// Returns the token string on success.
+ static Future<String> fetchToken(String baseUrl, String username, String password) async {
+ final dio = Dio(BaseOptions(
+ baseUrl: baseUrl,
+ connectTimeout: const Duration(seconds: 10),
+ receiveTimeout: const Duration(seconds: 15),
+ ));
+ final response = await dio.post(
+ '/api/token',
+ data: FormData.fromMap({
+ 'username': username,
+ 'password': password,
+ }),
+ );
+ 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);
+ return token;
}
Future<List<Platform>> getPlatforms() async {
- final response = await _dio.get('/api/platforms', options: _authOptions);
+ final response = await _dio.get('/api/platforms');
if (response.statusCode == 200) {
final List<dynamic> items;
if (response.data is Map && response.data.containsKey('items')) {
@@ -72,7 +101,6 @@ class RommService {
'limit': limit,
'offset': offset,
},
- options: _authOptions,
);
if (response.statusCode == 200) {
@@ -97,7 +125,6 @@ class RommService {
final response = await _dio.get(
'/api/saves',
queryParameters: {'game_id': gameId},
- options: _authOptions,
);
if (response.statusCode == 200) {
final List<dynamic> items;
@@ -115,12 +142,17 @@ class RommService {
);
}
- // --- New method added ---
String getDownloadUrl(Game game) {
final name = game.fileName ?? game.fsName ?? game.name;
final encoded = Uri.encodeComponent(name);
final baseUrl = config.baseUrl.endsWith('/') ? config.baseUrl.substring(0, config.baseUrl.length - 1) : config.baseUrl;
- final url = '$baseUrl/api/roms/${game.id}/content/$encoded';
- return url;
+ return '$baseUrl/api/roms/${game.id}/content/$encoded';
+ }
+
+ /// Returns the Bearer token Authorization header value for downloads.
+ String? get bearerAuthHeader {
+ final token = config.token;
+ if (token != null && token.isNotEmpty) return 'Bearer $token';
+ return null;
}
}
diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart
index 5c69d1d..ea8dd52 100644
--- a/lib/providers/library_provider.dart
+++ b/lib/providers/library_provider.dart
@@ -1,10 +1,25 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import '../core/romm/romm_models.dart';
import 'romm_provider.dart';
final searchQueryProvider = StateProvider<String>((ref) => '');
final selectedPlatformIdProvider = StateProvider<int?>((ref) => null);
-final cardAspectRatioProvider = StateProvider<double>((ref) => 0.75);
+
+final cardAspectRatioProvider = StateProvider<double>((ref) {
+ // Synchronous init — actual persisted value is loaded in _loadCardAspectRatio
+ // and set via the notifier. Default is 0.72 (square).
+ return 0.72;
+});
+
+// Loads persisted card aspect ratio into the provider on startup.
+final cardAspectRatioLoaderProvider = FutureProvider<void>((ref) async {
+ final prefs = await SharedPreferences.getInstance();
+ final saved = prefs.getDouble('card_aspect_ratio');
+ if (saved != null) {
+ ref.read(cardAspectRatioProvider.notifier).state = saved;
+ }
+});
final platformsProvider = FutureProvider<List<Platform>>((ref) async {
final service = ref.watch(rommServiceProvider);
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 017ee81..3ea8e4f 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -5,19 +5,25 @@ import 'package:freegosy/core/romm/romm_models.dart';
import 'package:freegosy/core/romm/romm_service.dart';
import 'package:freegosy/core/emulator/strategy_registry.dart';
-// Provider for loading RomMConfig (e.g., from SharedPreferences)
+// Provider for loading RomMConfig (including stored Bearer token)
final rommConfigProvider = FutureProvider<RomMConfig>((ref) async {
- // Placeholder for actual configuration loading.
- // In a real app, you'd load this from SharedPreferences, a config file, or API.
- // For demonstration, using placeholder values.
final prefs = await SharedPreferences.getInstance();
final baseUrl = prefs.getString('rommBaseUrl') ?? 'https://api.romm.example.com';
final username = prefs.getString('rommUsername') ?? 'guest';
final password = prefs.getString('rommPassword') ?? '';
+ final token = prefs.getString('rommAuthToken');
- return RomMConfig(baseUrl: baseUrl, username: username, password: password);
+ return RomMConfig(baseUrl: baseUrl, username: username, password: password, token: token);
});
+// Exposes a login function that fetches a Bearer token and refreshes the config/service providers.
+final loginProvider = Provider<Future<void> Function(String baseUrl, String username, String password)>((ref) {
+ return (baseUrl, username, password) async {
+ await RommService.fetchToken(baseUrl, username, password);
+ ref.invalidate(rommConfigProvider);
+ ref.invalidate(rommServiceProvider);
+ };
+});
// Simplified DirectoryService provider
final directoryServiceProvider = FutureProvider<DirectoryService?>((ref) async {
@@ -26,46 +32,37 @@ final directoryServiceProvider = FutureProvider<DirectoryService?>((ref) async {
await service.initialize();
return service;
} catch (e) {
- return null; // Return null on error
+ return null;
}
});
// Provider for StrategyRegistry
final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) {
- final directoryService = ref.watch(directoryServiceProvider).value; // Get the value from AsyncValue
-
- // Only create StrategyRegistry if DirectoryService is available
+ final directoryService = ref.watch(directoryServiceProvider).value;
if (directoryService != null) {
try {
return StrategyRegistry(directoryService);
} catch (e) {
- return null; // Return null on error
+ return null;
}
}
- return null; // Return null if DirectoryService is not ready
+ return null;
});
-
// Simplified RommService provider
final rommServiceProvider = Provider<RommService?>((ref) {
final rommConfigAsync = ref.watch(rommConfigProvider);
final directoryServiceAsync = ref.watch(directoryServiceProvider);
- // Get actual values, handle null/error states from AsyncValue
final config = rommConfigAsync.asData?.value;
final directoryService = directoryServiceAsync.asData?.value;
- // Only create RommService if config and directoryService are available
if (config != null && directoryService != null) {
try {
- // RommService constructor is RommService(this.config) and creates its own Dio instance.
return RommService(config);
} catch (e) {
- return null; // Return null on error during instantiation
+ return null;
}
}
- return null; // Return null if dependencies are not ready
+ return null;
});
-
-// Removed RommService class definition as it's imported.
-// Removed RommServiceExtension as it's no longer needed with the simplified provider.
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index 81d2b33..72ba99c 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -1,4 +1,3 @@
-import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/library_provider.dart';
@@ -30,13 +29,11 @@ class LibraryScreen extends ConsumerWidget {
return;
}
- // Use smart file detection
final existingRomPath = await dir.findExistingRomPath(game);
final expectedRomPath = await dir.getRomFilePath(game);
if (!context.mounted) return;
if (existingRomPath == null) {
- // ROM not found — show dialog with expected location
final shouldDownload = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -82,7 +79,6 @@ class LibraryScreen extends ConsumerWidget {
return;
}
- // ROM found — launch it
try {
await strategy.launch(game, existingRomPath);
} catch (e) {
@@ -102,10 +98,8 @@ class LibraryScreen extends ConsumerWidget {
return;
}
final url = service.getDownloadUrl(game);
- final u = service.config.username;
- final p = service.config.password;
- final token = 'Basic ${base64Encode(utf8.encode('$u:$p'))}';
- final headers = <String, String>{'Authorization': token};
+ final authHeader = service.bearerAuthHeader;
+ final headers = authHeader != null ? <String, String>{'Authorization': authHeader} : <String, String>{};
ref.read(downloadProvider.notifier).startDownload(game, url, headers: headers);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${game.name}...')),
@@ -120,9 +114,24 @@ class LibraryScreen extends ConsumerWidget {
final gamesAsync = ref.watch(allGamesProvider);
final filteredGames = ref.watch(filteredGamesProvider);
final cardAspectRatio = ref.watch(cardAspectRatioProvider);
+ final rommConfigAsync = ref.watch(rommConfigProvider);
+ final directoryServiceAsync = ref.watch(directoryServiceProvider);
+
+ // Build AppBar title: "Freegosy • hostname • N games"
+ final appBarTitle = rommConfigAsync.when(
+ data: (config) {
+ final uri = Uri.tryParse(config.baseUrl);
+ final host = uri?.host ?? config.baseUrl;
+ final totalGames = gamesAsync.asData?.value.length;
+ final gameCountStr = totalGames != null ? ' • $totalGames games' : '';
+ return 'Freegosy • $host$gameCountStr';
+ },
+ loading: () => 'Freegosy',
+ error: (_, __) => 'Freegosy',
+ );
return Scaffold(
- appBar: AppBar(title: const Text('Library')),
+ appBar: AppBar(title: Text(appBarTitle)),
body: Column(
children: [
Padding(
@@ -196,10 +205,24 @@ class LibraryScreen extends ConsumerWidget {
itemCount: filteredGames.length,
itemBuilder: (context, index) {
final game = filteredGames[index];
- return GameCard(
- game: game,
- onDownload: () => _startDownload(context, ref, game),
- onLaunch: () => _handleLaunch(context, ref, game),
+ final dirService = directoryServiceAsync.asData?.value;
+ if (dirService == null) {
+ return GameCard(
+ game: game,
+ onDownload: () => _startDownload(context, ref, game),
+ onLaunch: () => _handleLaunch(context, ref, game),
+ );
+ }
+ return FutureBuilder<bool>(
+ future: dirService.isRomDownloaded(game),
+ builder: (context, snapshot) {
+ return GameCard(
+ game: game,
+ isDownloaded: snapshot.data ?? false,
+ onDownload: () => _startDownload(context, ref, game),
+ onLaunch: () => _handleLaunch(context, ref, game),
+ );
+ },
);
},
),
@@ -213,4 +236,4 @@ class LibraryScreen extends ConsumerWidget {
),
);
}
-}
\ No newline at end of file
+}
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index 277b0bc..9d4dfea 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -8,6 +8,7 @@ import '../../core/storage/directory_service.dart';
import '../../core/emulator/emulator_registry_data.dart';
import '../../core/emulator/emulator_download_service.dart';
import '../../providers/romm_provider.dart';
+import '../../providers/library_provider.dart';
import '../../core/romm/romm_service.dart';
class SettingsScreen extends ConsumerStatefulWidget {
@@ -21,11 +22,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late TextEditingController _baseUrlController;
late TextEditingController _usernameController;
late TextEditingController _passwordController;
+ bool _isSaving = false;
@override
void initState() {
super.initState();
- // Initialize controllers here, will be updated when rommConfigProvider loads
_baseUrlController = TextEditingController();
_usernameController = TextEditingController();
_passwordController = TextEditingController();
@@ -41,40 +42,32 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
- // Watch DirectoryService (FutureProvider<DirectoryService?>)
final directoryServiceAsync = ref.watch(directoryServiceProvider);
- // Watch RommService (Provider<RommService?>)
final rommService = ref.watch(rommServiceProvider);
- // Watch RommConfig (FutureProvider<RomMConfig>) to pre-fill fields
final rommConfigAsync = ref.watch(rommConfigProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: rommConfigAsync.when(
data: (rommConfig) {
- // Pre-fill controllers with loaded config values
_baseUrlController.text = rommConfig.baseUrl;
_usernameController.text = rommConfig.username;
_passwordController.text = rommConfig.password;
return directoryServiceAsync.when(
data: (directoryService) {
- // Handle the case where DirectoryService is null (e.g., due to an error during initialization)
if (directoryService == null) {
return const Center(child: Text('Storage service not available.'));
}
-
- // Now check RommService. If it's null, it means it's still loading or encountered an error.
- // This check might be redundant if rommService is already being watched above, but kept for clarity.
if (rommService == null) {
- return const Center(child: CircularProgressIndicator()); // Show loading if RommService is null
+ return const Center(child: CircularProgressIndicator());
}
-
- // Both services are available and not null, render the UI
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
- _buildRommServerSection(context, ref, rommService), // Pass context and ref
+ _buildRommServerSection(context, ref, rommService),
+ const SizedBox(height: 24),
+ _buildCardAspectRatioSection(context, ref),
const SizedBox(height: 24),
_buildStorageSection(directoryService),
const SizedBox(height: 24),
@@ -92,7 +85,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
- Widget _buildRommServerSection(BuildContext context, WidgetRef ref, RommService? rommService) {
+ Widget _buildRommServerSection(BuildContext context, WidgetRef ref, rommService) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -130,7 +123,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
children: [
ElevatedButton(
onPressed: () async {
- // Test Connection Logic
if (rommService == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -158,26 +150,48 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
const SizedBox(width: 12),
ElevatedButton(
- onPressed: () async {
- // Save Logic
- final prefs = await SharedPreferences.getInstance();
- await prefs.setString('rommBaseUrl', _baseUrlController.text);
- await prefs.setString('rommUsername', _usernameController.text);
- await prefs.setString('rommPassword', _passwordController.text);
+ onPressed: _isSaving
+ ? null
+ : () async {
+ setState(() => _isSaving = true);
+ final baseUrl = _baseUrlController.text.trim();
+ final username = _usernameController.text.trim();
+ final password = _passwordController.text;
- // Refresh providers
- // ignore: unused_result
- ref.refresh(rommConfigProvider);
- // ignore: unused_result
- ref.refresh(rommServiceProvider);
+ try {
+ // Attempt login to get Bearer token
+ await RommService.fetchToken(baseUrl, username, password);
+ } catch (e) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Login failed: $e'), backgroundColor: Colors.red),
+ );
+ }
+ setState(() => _isSaving = false);
+ return;
+ }
- if (context.mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('RomM Server settings saved.'), backgroundColor: Colors.green),
- );
- }
- },
- child: const Text('Save'),
+ // Login succeeded — save credentials and refresh providers
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('rommBaseUrl', baseUrl);
+ await prefs.setString('rommUsername', username);
+ await prefs.setString('rommPassword', password);
+
+ // ignore: unused_result
+ ref.invalidate(rommConfigProvider);
+ // ignore: unused_result
+ ref.invalidate(rommServiceProvider);
+
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Logged in and settings saved.'), backgroundColor: Colors.green),
+ );
+ }
+ setState(() => _isSaving = false);
+ },
+ child: _isSaving
+ ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
+ : const Text('Save'),
),
],
),
@@ -185,6 +199,32 @@ 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,
@@ -194,7 +234,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_buildPathRow(
label: 'ROMs Directory',
currentPath: directoryService.romsRootPath,
- onChanged: (newPath) async { // Make onChanged async
+ onChanged: (newPath) async {
if (newPath != null) {
await directoryService.setRomsRoot(newPath);
// ignore: unused_result
@@ -206,7 +246,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_buildPathRow(
label: 'Emulators Directory',
currentPath: directoryService.emulatorsRootPath,
- onChanged: (newPath) async { // Make onChanged async
+ onChanged: (newPath) async {
if (newPath != null) {
await directoryService.setEmulatorsRoot(newPath);
// ignore: unused_result
@@ -248,8 +288,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
}
Widget _buildEmulatorsSection(DirectoryService directoryService) {
- // Instantiate EmulatorDownloadService here, as it needs Dio and DirectoryService.
- // RommService is no longer passed as an argument.
final emulatorDownloadService = EmulatorDownloadService(Dio(), directoryService);
return Column(
@@ -278,7 +316,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Expanded(child: Text(emulatorName)),
ElevatedButton(
onPressed: isInstalled
- ? null // Disabled if installed
+ ? null
: () async {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
diff --git a/lib/ui/widgets/game_card.dart b/lib/ui/widgets/game_card.dart
index c48a041..dc7e311 100644
--- a/lib/ui/widgets/game_card.dart
+++ b/lib/ui/widgets/game_card.dart
@@ -7,12 +7,14 @@ class GameCard extends ConsumerWidget {
final Game game;
final VoidCallback onDownload;
final VoidCallback onLaunch;
+ final bool isDownloaded;
const GameCard({
super.key,
required this.game,
required this.onDownload,
required this.onLaunch,
+ this.isDownloaded = false,
});
@override
@@ -30,15 +32,34 @@ class GameCard extends ConsumerWidget {
// Cover image - 75% height approximately
Expanded(
flex: 75,
- child: (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),
+ 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(