Skip to content

commit 29994a0

abduznik edited this page May 23, 2026 · 1 revision

feat(core): implement zero-wait ui snapshots and 8-digit pairing support

Commit: 29994a04f7d7b51c928f3d7bbdee8f8d503597e9

Author: abduznik

Date: 2026-05-01

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

Files Changed

lib/core/romm/library_snapshot_service.dart  | 78 +++++++++++++++++++++++
 lib/core/romm/romm_models.dart               | 32 ++++++++++
 lib/core/romm/romm_service.dart              | 25 +++++++-
 lib/core/storage/metadata_cache_service.dart | 94 +++++++++++++++++++++++-----
 4 files changed, 211 insertions(+), 18 deletions(-)
  • lib/core/romm/library_snapshot_service.dart
  • lib/core/romm/romm_models.dart
  • lib/core/romm/romm_service.dart
  • lib/core/storage/metadata_cache_service.dart

Diff

diff --git a/lib/core/romm/library_snapshot_service.dart b/lib/core/romm/library_snapshot_service.dart
new file mode 100644
index 0000000..cbdefa8
--- /dev/null
+++ b/lib/core/romm/library_snapshot_service.dart
@@ -0,0 +1,78 @@
+import 'dart:convert';
+import 'dart:io';
+import 'package:path_provider/path_provider.dart';
+import 'package:flutter/foundation.dart';
+import 'romm_models.dart';
+
+class LibrarySnapshotService {
+  static const String _platformsFile = 'platforms_snapshot.json';
+  static const String _collectionsFile = 'collections_snapshot.json';
+
+  Future<String> _getFilePath(String fileName) async {
+    final directory = await getApplicationDocumentsDirectory();
+    return '${directory.path}/$fileName';
+  }
+
+  Future<void> savePlatforms(List<Platform> platforms) async {
+    try {
+      final path = await _getFilePath(_platformsFile);
+      final jsonStr = jsonEncode(platforms.map((p) => p.toJson()).toList());
+      await File(path).writeAsString(jsonStr);
+    } catch (e) {
+      debugPrint('[LibrarySnapshot] Error saving platforms: $e');
+    }
+  }
+
+  Future<List<Platform>> loadPlatforms() async {
+    try {
+      final path = await _getFilePath(_platformsFile);
+      final file = File(path);
+      if (!await file.exists()) return [];
+      
+      final jsonStr = await file.readAsString();
+      final List<dynamic> decoded = jsonDecode(jsonStr);
+      return decoded.map((json) => Platform.fromJson(json)).toList();
+    } catch (e) {
+      debugPrint('[LibrarySnapshot] Error loading platforms: $e');
+      return [];
+    }
+  }
+
+  Future<void> saveCollections(List<Map<String, dynamic>> collections) async {
+    try {
+      final path = await _getFilePath(_collectionsFile);
+      final jsonStr = jsonEncode(collections);
+      await File(path).writeAsString(jsonStr);
+    } catch (e) {
+      debugPrint('[LibrarySnapshot] Error saving collections: $e');
+    }
+  }
+
+  Future<List<Map<String, dynamic>>> loadCollections() async {
+    try {
+      final path = await _getFilePath(_collectionsFile);
+      final file = File(path);
+      if (!await file.exists()) return [];
+      
+      final jsonStr = await file.readAsString();
+      final List<dynamic> decoded = jsonDecode(jsonStr);
+      return decoded.map((e) => e as Map<String, dynamic>).toList();
+    } catch (e) {
+      debugPrint('[LibrarySnapshot] Error loading collections: $e');
+      return [];
+    }
+  }
+
+  Future<void> clear() async {
+    try {
+      final pPath = await _getFilePath(_platformsFile);
+      final cPath = await _getFilePath(_collectionsFile);
+      final pFile = File(pPath);
+      final cFile = File(cPath);
+      if (await pFile.exists()) await pFile.delete();
+      if (await cFile.exists()) await cFile.delete();
+    } catch (e) {
+      debugPrint('[LibrarySnapshot] Error clearing snapshots: $e');
+    }
+  }
+}
diff --git a/lib/core/romm/romm_models.dart b/lib/core/romm/romm_models.dart
index e89c5fc..72063c6 100644
--- a/lib/core/romm/romm_models.dart
+++ b/lib/core/romm/romm_models.dart
@@ -238,6 +238,25 @@ class Firmware {
       updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null,
     );
   }
+
+  Map<String, dynamic> toJson() {
+    return {
+      'id': id,
+      'file_name': fileName,
+      'file_name_no_tags': fileNameNoTags,
+      'file_name_no_ext': fileNameNoExt,
+      'file_extension': fileExtension,
+      'file_path': filePath,
+      'file_size_bytes': fileSizeBytes,
+      'is_verified': isVerified,
+      'crc_hash': crcHash,
+      'md5_hash': md5Hash,
+      'sha1_hash': sha1Hash,
+      'missing_from_fs': missingFromFs,
+      'created_at': createdAt?.toIso8601String(),
+      'updated_at': updatedAt?.toIso8601String(),
+    };
+  }
 }
 
 class Platform {
@@ -277,6 +296,19 @@ class Platform {
       firmwareCount: json['firmware_count'] as int? ?? 0,
     );
   }
+
+  Map<String, dynamic> toJson() {
+    return {
+      'id': id,
+      'name': name,
+      'slug': slug,
+      'fs_slug': fsSlug,
+      'display_name': displayName,
+      'rom_count': gamesCount,
+      'firmware': firmware.map((e) => e.toJson()).toList(),
+      'firmware_count': firmwareCount,
+    };
+  }
 }
 
 class RomNote {
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index f8bceb2..d6bd79c 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -1,13 +1,13 @@
 import 'dart:convert';
 import 'dart:io' as io;
 import 'dart:math';
+import 'dart:async';
 import 'package:dio/dio.dart';
 import 'package:flutter/foundation.dart';
 import 'package:path/path.dart' as p;
 import 'package:shared_preferences/shared_preferences.dart';
 import '../storage/secure_storage_service.dart';
 import 'romm_models.dart';
-import 'dart:async';
 
 class RommService {
   RomMConfig _config;
@@ -147,6 +147,9 @@ class RommService {
     final headers = <String, dynamic>{};
     
     if (config.apiKey.isNotEmpty) {
+      // Send both headers for maximum compatibility across all RomM versions and proxies.
+      // Standard API Keys often work via X-Api-Key, while Client Tokens often require Bearer.
+      // Many setups use the API Key in the Bearer field, so we provide both to be safe.
       headers['Authorization'] = 'Bearer ${config.apiKey}';
       headers['X-Api-Key'] = config.apiKey;
     } else if (config.token != null && config.token!.isNotEmpty) {
@@ -182,6 +185,26 @@ class RommService {
     return token;
   }
 
+  static Future<String> exchangePairingCode(String baseUrl, String code) async {
+    final normalizedUrl = _normalizeBaseUrl(baseUrl);
+    final dio = Dio(BaseOptions(
+      baseUrl: normalizedUrl,
+      connectTimeout: const Duration(seconds: 10),
+      receiveTimeout: const Duration(seconds: 10),
+      headers: {'User-Agent': _ua},
+    ));
+
+    final response = await dio.post(
+      '/api/client-tokens/exchange',
+      data: {'code': code},
+    );
+
+    final token = response.data['raw_token'] as String?;
+    if (token == null || token.isEmpty) throw Exception('Pairing failed: no token in response');
+
+    return token;
+  }
+
   Future<void> refreshToken(SharedPreferences prefs) async {
     try {
       if (_config.username.isEmpty || _config.password.isEmpty) return;
diff --git a/lib/core/storage/metadata_cache_service.dart b/lib/core/storage/metadata_cache_service.dart
index 3828ab7..1d64b12 100644
--- a/lib/core/storage/metadata_cache_service.dart
+++ b/lib/core/storage/metadata_cache_service.dart
@@ -1,24 +1,43 @@
 import 'dart:convert';
-import 'package:shared_preferences/shared_preferences.dart';
+import 'dart:io';
+import 'package:path_provider/path_provider.dart';
+import 'package:flutter/foundation.dart';
 import '../romm/romm_models.dart';
 
 class MetadataCacheService {
-  static const String _gamesKey = 'cached_games_metadata';
-  final SharedPreferences _prefs;
+  static const String _gamesFile = 'games_cache.json';
+  static const String _countsFile = 'platform_counts.json';
+  
   List<Game> _cachedGames = [];
-
-  MetadataCacheService(this._prefs);
+  Map<String, int> _platformCounts = {};
 
   List<Game> get cachedGames => _cachedGames;
 
-  void load() {
+  MetadataCacheService();
+
+  Future<String> _getFilePath(String fileName) async {
+    final directory = await getApplicationDocumentsDirectory();
+    return '${directory.path}/$fileName';
+  }
+
+  Future<void> load() async {
     try {
-      final jsonStr = _prefs.getString(_gamesKey);
-      if (jsonStr == null) return;
-      
-      final List<dynamic> decoded = jsonDecode(jsonStr);
-      _cachedGames = decoded.map((json) => Game.fromJson(json)).toList();
-    } catch (_) {
+      final gamesPath = await _getFilePath(_gamesFile);
+      final gamesFile = File(gamesPath);
+      if (await gamesFile.exists()) {
+        final jsonStr = await gamesFile.readAsString();
+        final List<dynamic> decoded = jsonDecode(jsonStr);
+        _cachedGames = decoded.map((json) => Game.fromJson(json)).toList();
+      }
+
+      final countsPath = await _getFilePath(_countsFile);
+      final countsFile = File(countsPath);
+      if (await countsFile.exists()) {
+        final jsonStr = await countsFile.readAsString();
+        _platformCounts = Map<String, int>.from(jsonDecode(jsonStr));
+      }
+    } catch (e) {
+      debugPrint('[MetadataCache] Error loading cache: $e');
       _cachedGames = [];
     }
   }
@@ -30,14 +49,42 @@ class MetadataCacheService {
       gameMap[g.id] = g;
     }
     _cachedGames = gameMap.values.toList();
-    _persist();
+    await _persistGames();
+  }
+
+  Future<void> updatePlatformCount(String platformId, int count) async {
+    _platformCounts[platformId] = count;
+    await _persistCounts();
   }
 
-  void _persist() {
+  bool isPlatformValid(String platformId, int remoteCount) {
+    return _platformCounts[platformId] == remoteCount;
+  }
+
+  Future<void> invalidatePlatform(String platformId) async {
+    _cachedGames.removeWhere((g) => g.platformId.toString() == platformId);
+    _platformCounts.remove(platformId);
+    await _persistGames();
+    await _persistCounts();
+  }
+
+  Future<void> _persistGames() async {
     try {
-      final jsonList = _cachedGames.map((g) => g.toJson()).toList();
-      _prefs.setString(_gamesKey, jsonEncode(jsonList));
-    } catch (_) {}
+      final path = await _getFilePath(_gamesFile);
+      final jsonStr = jsonEncode(_cachedGames.map((g) => g.toJson()).toList());
+      await File(path).writeAsString(jsonStr);
+    } catch (e) {
+      debugPrint('[MetadataCache] Error persisting games: $e');
+    }
+  }
+
+  Future<void> _persistCounts() async {
+    try {
+      final path = await _getFilePath(_countsFile);
+      await File(path).writeAsString(jsonEncode(_platformCounts));
+    } catch (e) {
+      debugPrint('[MetadataCache] Error persisting counts: $e');
+    }
   }
 
   List<Game> getOfflineGames({
@@ -80,4 +127,17 @@ class MetadataCacheService {
       return true;
     }).toList();
   }
+
+  Future<void> clear() async {
+    _cachedGames = [];
+    _platformCounts = {};
+    try {
+      final gPath = await _getFilePath(_gamesFile);
+      final cPath = await _getFilePath(_countsFile);
+      final gFile = File(gPath);
+      final cFile = File(cPath);
+      if (await gFile.exists()) await gFile.delete();
+      if (await cFile.exists()) await cFile.delete();
+    } catch (_) {}
+  }
 }

Clone this wiki locally