Skip to content

commit 1198ca3

abduznik edited this page May 23, 2026 · 1 revision

feat: bearer auth, download badge, dark theme, and UI improvements

Commit: 1198ca353777e746a6d69c96cceec67c21bef2b5

Author: Claude

Date: 2026-03-23

Message

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

Files Changed

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.dart
  • lib/core/romm/romm_models.dart
  • lib/core/romm/romm_service.dart
  • lib/providers/library_provider.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/library_screen.dart
  • lib/ui/screens/settings_screen.dart
  • lib/ui/widgets/game_card.dart

Diff

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(

Clone this wiki locally