Skip to content

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