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