Skip to content

Navigation Menu

Sign in
Sign up

commit 4715ac5

abduznik edited this page May 23, 2026 · 1 revision

feat(ui): integrate zero-wait hydration and redesign pairing flow

Commit: 4715ac5843f433418028b16ddda91b80983420d5

Author: abduznik

Date: 2026年05月01日

Why: Adds a new feature or capability to the application.

Files Changed

lib/providers/library_provider.dart | 162 ++++++++++++----------------
 lib/providers/paginated_games_provider.dart | 25 ++++-
 lib/providers/romm_provider.dart | 14 ++-
 lib/ui/screens/game_detail_screen.dart | 6 ++
 lib/ui/screens/onboarding_screen.dart | 104 ++++++++++++++----
 lib/ui/screens/settings_screen.dart | 87 ++++++++++++++-
 6 files changed, 276 insertions(+), 122 deletions(-)
  • lib/providers/library_provider.dart
  • lib/providers/paginated_games_provider.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/game_detail_screen.dart
  • lib/ui/screens/onboarding_screen.dart
  • lib/ui/screens/settings_screen.dart

Diff

diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart
index d5451fc..36cad4b 100644
--- a/lib/providers/library_provider.dart
+++ b/lib/providers/library_provider.dart
@@ -1,62 +1,12 @@
 import 'dart:typed_data';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:shared_preferences/shared_preferences.dart';
 import 'dart:convert';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:http/http.dart' as http;
 import '../core/romm/romm_models.dart';
 import 'romm_provider.dart';
 import 'shared_prefs_provider.dart';
 
-const String _gamesCacheKey = 'cached_games';
-const String _platformsCacheKey = 'cached_platforms_v2';
-const String _gamesCacheTimeKey = 'cached_games_time';
-const String _platformsCacheTimeKey = 'cached_platforms_time';
-const int _cacheExpiryDays = 7;
-
-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<List<Game>?> _loadGamesCache(Ref ref) async {
- final prefs = ref.read(sharedPreferencesProvider);
- 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(Ref ref) async {
- final prefs = ref.read(sharedPreferencesProvider);
- 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;
- }
-}
+// Old cache functions removed in favor of LibrarySnapshotService and MetadataCacheService
 
 const Map<String, Map<String, dynamic>> kDisplayPresets = {
 'windows_best': {
@@ -104,83 +54,111 @@ final activePresetLoaderProvider = FutureProvider<void>((ref) async {});
 
 final platformsProvider = FutureProvider<List<Platform>>((ref) async {
 final service = ref.watch(rommServiceProvider);
+ final snapshotService = ref.watch(librarySnapshotServiceProvider);
 ref.watch(isOfflineProvider);
- if (service == null) return [];
+ 
+ final snapshot = await snapshotService.loadPlatforms();
+ 
+ if (service == null) {
+ return snapshot.where((p) => p.gamesCount > 0).toList();
+ }
 
- final cached = await _loadPlatformsCache(ref);
- if (cached != null) {
+ if (snapshot.isNotEmpty) {
+ // Background refresh
 Future.microtask(() async {
 try {
 final fresh = await service.getPlatforms();
 if (fresh.isNotEmpty) {
- final prefs = ref.read(sharedPreferencesProvider);
- final jsonList = fresh.map((p) => {
- 'id': p.id,
- 'name': p.name,
- 'slug': p.slug,
- 'games_count': p.gamesCount,
- }).toList();
- await prefs.setString(_platformsCacheKey, jsonEncode(jsonList));
- await prefs.setString(_platformsCacheTimeKey, DateTime.now().toIso8601String());
+ await snapshotService.savePlatforms(fresh);
 }
 } catch (_) {}
 });
- return cached.where((p) => p.gamesCount > 0).toList();
+ return snapshot.where((p) => p.gamesCount > 0).toList();
 }
 
 final platforms = await service.getPlatforms();
 if (platforms.isNotEmpty) {
- final prefs = ref.read(sharedPreferencesProvider);
- final jsonList = platforms.map((p) => {
- 'id': p.id,
- 'name': p.name,
- 'slug': p.slug,
- 'games_count': p.gamesCount,
- }).toList();
- await prefs.setString(_platformsCacheKey, jsonEncode(jsonList));
- await prefs.setString(_platformsCacheTimeKey, DateTime.now().toIso8601String());
+ await snapshotService.savePlatforms(platforms);
 }
 return platforms.where((p) => p.gamesCount > 0).toList();
 });
 
+final collectionsProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
+ final service = ref.watch(rommServiceProvider);
+ final snapshotService = ref.watch(librarySnapshotServiceProvider);
+ ref.watch(isOfflineProvider);
+
+ final snapshot = await snapshotService.loadCollections();
+ 
+ if (service == null) return snapshot;
+
+ if (snapshot.isNotEmpty) {
+ // Background refresh
+ Future.microtask(() async {
+ try {
+ final fresh = await service.getCollections();
+ if (fresh.isNotEmpty) {
+ await snapshotService.saveCollections(fresh);
+ }
+ } catch (_) {}
+ });
+ return snapshot;
+ }
+
+ final collections = await service.getCollections();
+ if (collections.isNotEmpty) {
+ await snapshotService.saveCollections(collections);
+ }
+ return collections;
+});
+
 final allGamesProvider = FutureProvider<List<Game>>((ref) async {
 final service = ref.watch(rommServiceProvider);
+ final cacheService = await ref.watch(metadataCacheServiceProvider.future);
 ref.watch(isOfflineProvider);
+ 
 final selectedPlatformId = ref.watch(selectedPlatformIdProvider);
- if (service == null) return [];
-
 final platformIdStr = selectedPlatformId?.toString();
 
+ if (service == null) {
+ return cacheService.getOfflineGames(platformId: platformIdStr);
+ }
+
 if (platformIdStr != null) {
- try {
- return await service.getAllGames(platformId: platformIdStr);
- } catch (e) {
- return await service.getAllGames();
+ // Check if platform cache is valid
+ final platforms = await ref.watch(platformsProvider.future);
+ final platform = platforms.firstWhere((p) => p.id.toString() == platformIdStr, orElse: () => Platform(id: 0, name: '', slug: ''));
+ 
+ if (platform.id != 0 && cacheService.isPlatformValid(platformIdStr, platform.gamesCount)) {
+ final cached = cacheService.getOfflineGames(platformId: platformIdStr);
+ if (cached.isNotEmpty) return cached;
+ }
+
+ final games = await service.getAllGames(platformId: platformIdStr);
+ if (games.isNotEmpty) {
+ await cacheService.saveGames(games);
+ await cacheService.updatePlatformCount(platformIdStr, platform.gamesCount);
 }
+ return games;
 }
 
- final cached = await _loadGamesCache(ref);
- if (cached != null) {
+ // General all-games view
+ final cached = cacheService.cachedGames;
+ if (cached.isNotEmpty) {
 Future.microtask(() async {
 try {
- final fresh = await service.getAllGames(platformId: platformIdStr);
+ final fresh = await service.getAllGames();
 if (fresh.isNotEmpty) {
- final prefs = ref.read(sharedPreferencesProvider);
- final jsonList = fresh.map((g) => g.toJson()).toList();
- await prefs.setString(_gamesCacheKey, jsonEncode(jsonList));
- await prefs.setString(_gamesCacheTimeKey, DateTime.now().toIso8601String());
+ await cacheService.saveGames(fresh);
 }
 } catch (_) {}
 });
 return cached;
 }
 
- final games = await service.getAllGames(platformId: platformIdStr);
+ final games = await service.getAllGames();
 if (games.isNotEmpty) {
- final prefs = ref.read(sharedPreferencesProvider);
- final jsonList = games.map((g) => g.toJson()).toList();
- await prefs.setString(_gamesCacheKey, jsonEncode(jsonList));
- await prefs.setString(_gamesCacheTimeKey, DateTime.now().toIso8601String());
+ await cacheService.saveGames(games);
 }
 return games;
 });
diff --git a/lib/providers/paginated_games_provider.dart b/lib/providers/paginated_games_provider.dart
index b33a1da..456b4af 100644
--- a/lib/providers/paginated_games_provider.dart
+++ b/lib/providers/paginated_games_provider.dart
@@ -122,7 +122,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
 _currentSearch = search;
 final key = _key(platformId, search);
 
- // Serve from cache immediately if available
+ // Serve from memory cache immediately if available
 if (_cache.containsKey(key)) {
 state = PaginatedGamesState(
 games: _cache[key]!,
@@ -135,6 +135,29 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
 return;
 }
 
+ // Serve from persistent cache if available
+ final cacheService = _ref.read(metadataCacheServiceProvider).value;
+ if (cacheService != null) {
+ final offline = cacheService.getOfflineGames(
+ platformId: platformId,
+ search: search,
+ genres: _activeFilters.genres,
+ regions: _activeFilters.regions,
+ languages: _activeFilters.languages,
+ );
+ 
+ if (offline.isNotEmpty) {
+ state = PaginatedGamesState(
+ games: offline,
+ total: offline.length,
+ hasMore: true,
+ isLoading: false,
+ );
+ _backgroundRefresh(platformId: platformId, search: search, key: key);
+ return;
+ }
+ }
+
 // No cache — show loading and fetch
 state = const PaginatedGamesState(isLoading: true);
 final service = _ref.read(rommServiceProvider);
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 8636875..727698e 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -9,10 +9,11 @@ import 'package:freegosy/core/save/backup_repository.dart';
 import 'package:freegosy/core/save/backup_service.dart';
 import 'package:freegosy/core/emulator/strategies/windows_strategy.dart';
 import 'package:freegosy/core/emulator/emulator_registry_data.dart';
-import 'package:freegosy/core/storage/download_cache_service.dart';
+import 'package:freegosy/core/romm/rom_scanner_service.dart';
+import 'package:freegosy/core/romm/library_snapshot_service.dart';
 import 'package:freegosy/core/storage/metadata_cache_service.dart';
 import 'package:freegosy/core/storage/rom_mapping_service.dart';
-import 'package:freegosy/core/romm/rom_scanner_service.dart';
+import 'package:freegosy/core/storage/download_cache_service.dart';
 import 'package:freegosy/providers/custom_emulators_provider.dart';
 import 'package:freegosy/providers/shared_prefs_provider.dart';
 import 'package:freegosy/core/emulator/firmware_service.dart';
@@ -176,12 +177,15 @@ final rommServiceProvider = Provider<RommService?>((ref) {
 });
 
 final metadataCacheServiceProvider = FutureProvider<MetadataCacheService>((ref) async {
- final prefs = ref.watch(sharedPreferencesProvider);
- final service = MetadataCacheService(prefs);
- service.load();
+ final service = MetadataCacheService();
+ await service.load();
 return service;
 });
 
+final librarySnapshotServiceProvider = Provider<LibrarySnapshotService>((ref) {
+ return LibrarySnapshotService();
+});
+
 final romMappingServiceProvider = FutureProvider<RomMappingService>((ref) async {
 final service = RomMappingService();
 await service.init();
diff --git a/lib/ui/screens/game_detail_screen.dart b/lib/ui/screens/game_detail_screen.dart
index 77ee52b..0b48c2f 100644
--- a/lib/ui/screens/game_detail_screen.dart
+++ b/lib/ui/screens/game_detail_screen.dart
@@ -103,6 +103,12 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
 _syncStateWithGame(updated);
 });
 _checkDownloadStatus();
+
+ // Update persistent cache so the list view and offline mode have the latest details
+ final cacheService = ref.read(metadataCacheServiceProvider).value;
+ if (cacheService != null) {
+ await cacheService.saveGames([updated]);
+ }
 }
 } catch (_) {}
 }
diff --git a/lib/ui/screens/onboarding_screen.dart b/lib/ui/screens/onboarding_screen.dart
index 783158b..cf1d716 100644
--- a/lib/ui/screens/onboarding_screen.dart
+++ b/lib/ui/screens/onboarding_screen.dart
@@ -26,6 +26,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
 // Step 1: Server Config
 final _baseUrlController = TextEditingController();
 final _apiKeyController = TextEditingController();
+ final _pairingCodeController = TextEditingController();
+ bool _usePairingCode = false;
 bool _isTesting = false;
 String? _testError;
 bool _testSuccess = false;
@@ -97,9 +99,24 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
 });
 
 try {
+ String apiKey = _apiKeyController.text.trim();
+ String? authToken;
+
+ if (_usePairingCode) {
+ final code = _pairingCodeController.text.trim().replaceAll(RegExp(r'[^a-zA-Z0-9]'), '');
+ if (code.isEmpty) {
+ setState(() => _testError = 'Please enter a pairing code');
+ return;
+ }
+ final token = await RommService.exchangePairingCode(url, code);
+ _apiKeyController.text = token;
+ apiKey = token;
+ }
+
 final testConfig = RomMConfig(
 baseUrl: url,
- apiKey: _apiKeyController.text.trim(),
+ apiKey: apiKey,
+ token: authToken,
 username: '',
 password: '',
 );
@@ -298,26 +315,75 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
 ),
 ),
 ),
- const SizedBox(height: 20),
- TextField(
- controller: _apiKeyController,
- decoration: InputDecoration(
- labelText: 'API Key',
- hintText: 'Found in RomM User Settings',
- prefixIcon: const Icon(Icons.key),
- suffixIcon: IconButton(
- icon: const Icon(Icons.paste),
- onPressed: () async {
- final data = await Clipboard.getData(Clipboard.kTextPlain);
- if (data != null && data.text != null) {
- _apiKeyController.text = data.text!;
- }
- },
- tooltip: 'Paste from clipboard',
+ const SizedBox(height: 32),
+ Row(
+ children: [
+ Expanded(
+ child: InkWell(
+ onTap: () => setState(() => _usePairingCode = false),
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ decoration: BoxDecoration(
+ border: Border(bottom: BorderSide(color: !_usePairingCode ? Colors.deepPurple : Colors.transparent, width: 2)),
+ ),
+ child: Text('API KEY', textAlign: TextAlign.center, style: TextStyle(color: !_usePairingCode ? Colors.white : Colors.grey, fontWeight: FontWeight.bold)),
+ ),
+ ),
 ),
- ),
- obscureText: true,
+ Expanded(
+ child: InkWell(
+ onTap: () => setState(() => _usePairingCode = true),
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ decoration: BoxDecoration(
+ border: Border(bottom: BorderSide(color: _usePairingCode ? Colors.deepPurple : Colors.transparent, width: 2)),
+ ),
+ child: Text('PAIRING CODE', textAlign: TextAlign.center, style: TextStyle(color: _usePairingCode ? Colors.white : Colors.grey, fontWeight: FontWeight.bold)),
+ ),
+ ),
+ ),
+ ],
 ),
+ const SizedBox(height: 24),
+ if (!_usePairingCode)
+ TextField(
+ controller: _apiKeyController,
+ decoration: InputDecoration(
+ labelText: 'API Key',
+ hintText: 'Found in RomM User Settings',
+ prefixIcon: const Icon(Icons.key),
+ suffixIcon: IconButton(
+ icon: const Icon(Icons.paste),
+ onPressed: () async {
+ final data = await Clipboard.getData(Clipboard.kTextPlain);
+ if (data != null && data.text != null) {
+ _apiKeyController.text = data.text!;
+ }
+ },
+ tooltip: 'Paste from clipboard',
+ ),
+ ),
+ obscureText: true,
+ )
+ else
+ TextField(
+ controller: _pairingCodeController,
+ decoration: InputDecoration(
+ labelText: '8-Digit Pairing Code',
+ hintText: 'Generated in RomM Web UI',
+ prefixIcon: const Icon(Icons.phonelink_setup),
+ suffixIcon: IconButton(
+ icon: const Icon(Icons.paste),
+ onPressed: () async {
+ final data = await Clipboard.getData(Clipboard.kTextPlain);
+ if (data != null && data.text != null) {
+ _pairingCodeController.text = data.text!;
+ }
+ },
+ tooltip: 'Paste from clipboard',
+ ),
+ ),
+ ),
 const SizedBox(height: 32),
 if (_testError != null)
 Container(
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index e5314cb..7e6d046 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -33,7 +33,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
 bool _isLegacyAuth = false;
 bool _isTestingConnection = false;
 String? _connectionError;
- bool _connectionSuccess = false;
+ String? _pairedToken;
 
 @override
 void initState() {
@@ -159,11 +159,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
 padding: const EdgeInsets.only(bottom: 12),
 child: Text(_connectionError!, style: const TextStyle(color: Colors.red, fontSize: 13)),
 ),
- if (_connectionSuccess)
 Padding(
 padding: const EdgeInsets.only(bottom: 12),
 child: Text('Connection successful!', style: TextStyle(color: Colors.green, fontSize: 13)),
 ),
+ Row(
+ children: [
+ ElevatedButton.icon(
+ onPressed: () => _showPairingDialog(context),
+ icon: const Icon(Icons.phonelink_setup),
+ label: const Text('Pair New Device'),
+ ),
+ const Spacer(),
+ ],
+ ),
+ const SizedBox(height: 12),
 Row(
 children: [
 ElevatedButton(
@@ -177,7 +187,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
 setState(() {
 _isTestingConnection = true;
 _connectionError = null;
- _connectionSuccess = false;
 });
 
 try {
@@ -186,7 +195,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
 baseUrl: baseUrl,
 username: _usernameController.text.trim(),
 password: _passwordController.text,
- apiKey: _apiKeyController.text.trim(),
+ apiKey: _pairedToken == null ? _apiKeyController.text.trim() : '',
+ token: _pairedToken,
 );
 
 final testService = RommService(testConfig);
@@ -195,7 +205,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
 if (mounted) {
 setState(() {
 _isTestingConnection = false;
- _connectionSuccess = true;
 });
 }
 } catch (e) {
@@ -223,9 +232,20 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
 if (_isLegacyAuth) {
 await prefs.setString('rommUsername', _usernameController.text.trim());
 await SecureStorageService.write('rommPassword', _passwordController.text, prefs);
+ await SecureStorageService.delete('rommApiKey', prefs);
+ await SecureStorageService.delete('rommAuthToken', prefs);
+ } else if (_pairedToken != null) {
+ await SecureStorageService.write('rommAuthToken', _pairedToken!, prefs);
+ await SecureStorageService.delete('rommApiKey', prefs);
+ await prefs.setString('rommUsername', '');
+ await SecureStorageService.delete('rommPassword', prefs);
 } else {
 await SecureStorageService.write('rommApiKey', _apiKeyController.text.trim(), prefs);
+ await SecureStorageService.delete('rommAuthToken', prefs);
+ await prefs.setString('rommUsername', '');
+ await SecureStorageService.delete('rommPassword', prefs);
 }
+ _pairedToken = null;
 ref.invalidate(rommConfigProvider);
 ref.invalidate(rommServiceProvider);
 if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Settings saved.')));
@@ -397,6 +417,63 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
 Widget _buildRetroArchSettingsSection(BuildContext context, WidgetRef ref) => const SizedBox();
 Widget _buildLinuxSettingsSection(BuildContext context, WidgetRef ref, DirectoryService directoryService) => const SizedBox();
 Widget _buildLegalSection(BuildContext context) => const SizedBox();
+
+ void _showPairingDialog(BuildContext context) {
+ final codeController = TextEditingController();
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Pair with Web UI'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text('Enter the 8-digit code generated in your RomM Web UI settings.'),
+ const SizedBox(height: 16),
+ TextField(
+ controller: codeController,
+ autofocus: true,
+ decoration: const InputDecoration(
+ labelText: 'Pairing Code',
+ hintText: 'XXXXXXXX',
+ border: OutlineInputBorder(),
+ ),
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 24, letterSpacing: 4, fontWeight: FontWeight.bold, fontFamily: 'monospace'),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
+ ElevatedButton(
+ onPressed: () async {
+ final code = codeController.text.trim().replaceAll(RegExp(r'[^a-zA-Z0-9]'), '');
+ if (code.length < 8) return;
+ 
+ try {
+ final url = _baseUrlController.text.trim();
+ if (url.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter a Server URL first.')));
+ return;
+ }
+ final token = await RommService.exchangePairingCode(url, code);
+ _apiKeyController.text = token;
+ setState(() => _isLegacyAuth = false);
+ if (context.mounted) {
+ Navigator.pop(context);
+ ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Successfully paired! Click Save to apply.')));
+ }
+ } catch (e) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Pairing failed: ${e.toString().split('\n').first}')));
+ }
+ }
+ },
+ child: const Text('Pair'),
+ ),
+ ],
+ ),
+ );
+ }
 }
 
 class _LogsDialogContent extends StatefulWidget {

Clone this wiki locally

AltStyle によって変換されたページ (->オリジナル) /