-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 8c1616d
abduznik edited this page May 23, 2026
·
1 revision
Commit: 8c1616db57b73a3a0a9c01cc85b467e47bfe64cb
Author: abduznik
Date: 2026-04-28
Why: Adds a new feature or capability to the application.
lib/app.dart | 61 ++--
lib/core/save/save_sync_service.dart | 49 +++
lib/providers/romm_provider.dart | 1 +
lib/ui/screens/library_actions.dart | 26 ++
lib/ui/screens/onboarding_screen.dart | 571 +++++++++++++++++++++++++++++++
lib/ui/widgets/save_conflict_dialog.dart | 123 +++++++
pubspec.lock | 8 +
pubspec.yaml | 1 +
test/unit/save_sync_service_test.dart | 56 ++-
9 files changed, 867 insertions(+), 29 deletions(-)
lib/app.dartlib/core/save/save_sync_service.dartlib/providers/romm_provider.dartlib/ui/screens/library_actions.dartlib/ui/screens/onboarding_screen.dartlib/ui/widgets/save_conflict_dialog.dartpubspec.lockpubspec.yamltest/unit/save_sync_service_test.dart
diff --git a/lib/app.dart b/lib/app.dart
index e250cca..6b1a6dd 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -10,6 +10,7 @@ import 'core/save/background_sync_queue.dart';
import 'ui/screens/library_screen.dart';
import 'ui/screens/download_screen.dart';
import 'ui/screens/settings_screen.dart';
+import 'ui/screens/onboarding_screen.dart';
import 'providers/ui_provider.dart';
class CustomScrollBehavior extends MaterialScrollBehavior {
@@ -122,28 +123,44 @@ class _FreegosyAppState extends ConsumerState<FreegosyApp> {
),
),
),
- home: Scaffold(
- body: _screens[currentIndex],
- bottomNavigationBar: NavigationBar(
- selectedIndex: currentIndex,
- onDestinationSelected: (index) {
- ref.read(currentTabIndexProvider.notifier).state = index;
- },
- destinations: const [
- NavigationDestination(
- icon: Icon(Icons.library_books),
- label: 'Library',
- ),
- NavigationDestination(
- icon: Icon(Icons.download),
- label: 'Downloads',
- ),
- NavigationDestination(
- icon: Icon(Icons.settings),
- label: 'Settings',
- ),
- ],
- ),
+ home: Consumer(
+ builder: (context, ref, _) {
+ final isOnboardedAsync = ref.watch(rommConfigProvider);
+
+ return isOnboardedAsync.when(
+ data: (config) {
+ if (config.baseUrl.isEmpty) {
+ return const OnboardingScreen();
+ }
+
+ return Scaffold(
+ body: _screens[currentIndex],
+ bottomNavigationBar: NavigationBar(
+ selectedIndex: currentIndex,
+ onDestinationSelected: (index) {
+ ref.read(currentTabIndexProvider.notifier).state = index;
+ },
+ destinations: const [
+ NavigationDestination(
+ icon: Icon(Icons.library_books),
+ label: 'Library',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.download),
+ label: 'Downloads',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.settings),
+ label: 'Settings',
+ ),
+ ],
+ ),
+ );
+ },
+ loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
+ error: (e, s) => Scaffold(body: Center(child: Text('Error: $e'))),
+ );
+ },
),
),
);
diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart
index 7043cd0..866eb64 100644
--- a/lib/core/save/save_sync_service.dart
+++ b/lib/core/save/save_sync_service.dart
@@ -25,6 +25,25 @@ import 'strategies/cemu_save_strategy.dart';
import 'strategies/azahar_save_strategy.dart';
import '../emulator/strategy_registry.dart';
+class SaveConflictException implements Exception {
+ final Game game;
+ final DateTime localTime;
+ final DateTime cloudTime;
+ final String? localScreenshot;
+ final String? cloudScreenshot;
+
+ SaveConflictException({
+ required this.game,
+ required this.localTime,
+ required this.cloudTime,
+ this.localScreenshot,
+ this.cloudScreenshot,
+ });
+
+ @override
+ String toString() => 'Conflict detected for ${game.name}: Local ($localTime) vs Cloud ($cloudTime)';
+}
+
class SaveSyncService {
final RommService _rommService;
final DirectoryService _directoryService;
@@ -248,6 +267,34 @@ class SaveSyncService {
);
if (filesMap.isEmpty) return false;
+ // --- Conflict Detection ---
+ if (!force) {
+ final latestRemote = await _rommService.getLatestSave(game.id);
+ if (latestRemote != null) {
+ final remoteTime = DateTime.tryParse(latestRemote['updated_at']?.toString() ?? '');
+ final lastPull = _getLastPullTime(game.id);
+
+ // If remote is newer than our last pull, and we have local changes -> Conflict!
+ if (remoteTime != null && lastPull != null && remoteTime.isAfter(lastPull)) {
+ // Find the newest local file time
+ DateTime? localTime;
+ for (final file in filesMap.keys) {
+ final mtime = await file.lastModified();
+ if (localTime == null || mtime.isAfter(localTime)) localTime = mtime;
+ }
+
+ if (localTime != null && remoteTime.isAfter(lastPull)) {
+ throw SaveConflictException(
+ game: game,
+ localTime: localTime,
+ cloudTime: remoteTime,
+ cloudScreenshot: latestRemote['screenshot_path'] ?? latestRemote['screenshot_url'],
+ );
+ }
+ }
+ }
+ }
+
int uploaded = 0;
final displayStem = game.displayName.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
final tempDir = await _directoryService.getEmulatorDirectory('temp');
@@ -308,6 +355,8 @@ class SaveSyncService {
await _rommService.pruneOldSaves(game.id);
}
return uploaded > 0;
+ } on SaveConflictException {
+ rethrow;
} catch (e) {
debugPrint('[Sync] Error in pushSaves: $e');
return false;
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 7331cd7..ed46e9f 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -209,3 +209,4 @@ final backupRepositoryProvider = Provider<BackupRepository>((ref) {
/// Lightweight service for creating and restoring local save backups.
final backupServiceProvider = Provider<BackupService>((ref) => BackupService());
+
diff --git a/lib/ui/screens/library_actions.dart b/lib/ui/screens/library_actions.dart
index d04c413..080cb79 100644
--- a/lib/ui/screens/library_actions.dart
+++ b/lib/ui/screens/library_actions.dart
@@ -24,6 +24,8 @@ import '../../core/emulator/strategies/windows_strategy.dart';
import '../../core/emulator/strategies/retroarch_strategy.dart';
import '../widgets/windows_game_config_dialog.dart';
import '../widgets/multi_disc_picker.dart';
+import '../widgets/save_conflict_dialog.dart';
+import '../../core/save/save_sync_service.dart';
mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
// These need to be implemented by the state class
@@ -206,6 +208,17 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
await syncService.pushSaves(game, romPath, syncMode: syncMode);
}
}
+ } on SaveConflictException catch (e) {
+ if (!context.mounted) return;
+ final choice = await showDialog<String>(
+ context: context,
+ builder: (ctx) => SaveConflictDialog(conflict: e),
+ );
+ if (choice == 'local' && context.mounted) {
+ await syncService.pushSaves(game, romPath, syncMode: syncMode, force: true);
+ } else if (choice == 'cloud' && context.mounted) {
+ await syncService.pullSave(game, romPath);
+ }
} catch (e) {
// Ignore other push errors during launch to not block playing
}
@@ -520,6 +533,19 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (!context.mounted) return;
return handlePushSaves(context, ref, game);
}
+ } on SaveConflictException catch (e) {
+ if (!context.mounted) return;
+ final choice = await showDialog<String>(
+ context: context,
+ builder: (ctx) => SaveConflictDialog(conflict: e),
+ );
+ if (choice == 'local' && context.mounted) {
+ await syncService.pushSaves(game, romPath, syncMode: syncMode, force: true);
+ if (context.mounted) ErrorHandler.showSuccess(context, 'Sync Resolved', message: 'Local save uploaded');
+ } else if (choice == 'cloud' && context.mounted) {
+ await syncService.pullSave(game, romPath);
+ if (context.mounted) ErrorHandler.showSuccess(context, 'Sync Resolved', message: 'Cloud save restored');
+ }
} catch (e) {
if (!context.mounted) return;
ErrorHandler.showException(context, e, contextLabel: 'Push Saves Error');
diff --git a/lib/ui/screens/onboarding_screen.dart b/lib/ui/screens/onboarding_screen.dart
new file mode 100644
index 0000000..f7e8c63
--- /dev/null
+++ b/lib/ui/screens/onboarding_screen.dart
@@ -0,0 +1,571 @@
+import 'dart:io' as io;
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:path/path.dart' as p;
+
+import '../../providers/romm_provider.dart';
+import '../../providers/shared_prefs_provider.dart';
+import '../../core/romm/romm_service.dart';
+import '../../core/romm/romm_models.dart';
+import '../../core/storage/secure_storage_service.dart';
+
+class OnboardingScreen extends ConsumerStatefulWidget {
+ const OnboardingScreen({super.key});
+
+ @override
+ ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
+}
+
+class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
+ final PageController _pageController = PageController();
+ int _currentStep = 0;
+ final int _totalSteps = 4;
+
+ // Step 1: Server Config
+ final _baseUrlController = TextEditingController();
+ final _apiKeyController = TextEditingController();
+ bool _isTesting = false;
+ String? _testError;
+ bool _testSuccess = false;
+
+ // Step 2: Storage Config
+ String? _romsRoot;
+ String? _emusRoot;
+ String _linuxPreset = 'default';
+ bool _isStorageInitialized = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _baseUrlController.text = 'http://';
+ _loadExistingConfig();
+ }
+
+ Future<void> _loadExistingConfig() async {
+ final prefs = ref.read(sharedPreferencesProvider);
+ final baseUrl = prefs.getString('rommBaseUrl') ?? '';
+ final apiKey = await SecureStorageService.read('rommApiKey', prefs) ?? '';
+ final romsRoot = prefs.getString('romsRootPath');
+ final emusRoot = prefs.getString('emulatorsRootPath');
+ final linuxPreset = prefs.getString('linuxSyncPreset') ?? 'default';
+
+ if (!mounted) return;
+
+ setState(() {
+ if (baseUrl.isNotEmpty && baseUrl != 'http://') {
+ _baseUrlController.text = baseUrl;
+ }
+ _apiKeyController.text = apiKey;
+ if (romsRoot != null) _romsRoot = romsRoot;
+ if (emusRoot != null) _emusRoot = emusRoot;
+ _linuxPreset = linuxPreset;
+ if (romsRoot != null || emusRoot != null) {
+ _isStorageInitialized = true;
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ _baseUrlController.dispose();
+ _apiKeyController.dispose();
+ super.dispose();
+ }
+
+ Future<void> _testConnection() async {
+ final url = _baseUrlController.text.trim();
+ if (url.isEmpty || url == 'http://' || url == 'https://') {
+ setState(() => _testError = 'Please enter a valid Server URL');
+ return;
+ }
+
+ setState(() {
+ _isTesting = true;
+ _testError = null;
+ _testSuccess = false;
+ });
+
+ try {
+ final testConfig = RomMConfig(
+ baseUrl: url,
+ apiKey: _apiKeyController.text.trim(),
+ username: '',
+ password: '',
+ );
+ final testService = RommService(testConfig);
+ await testService.getPlatforms(); // Quick check
+
+ setState(() {
+ _isTesting = false;
+ _testSuccess = true;
+ });
+ } catch (e) {
+ setState(() {
+ _isTesting = false;
+ _testError = 'Connection failed: ${e.toString().split('\n').first}';
+ });
+ }
+ }
+
+ Future<void> _initializeDefaultStorage() async {
+ if (_isStorageInitialized) return;
+ final dirService = await ref.read(directoryServiceProvider.future);
+ if (dirService != null) {
+ setState(() {
+ _romsRoot = dirService.romsRootPath;
+ _emusRoot = dirService.emulatorsRootPath;
+ _isStorageInitialized = true;
+ });
+ }
+ }
+
+ Future<void> _finishOnboarding() async {
+ final prefs = ref.read(sharedPreferencesProvider);
+
+ // Save RomM Config
+ await prefs.setString('rommBaseUrl', _baseUrlController.text.trim());
+ await SecureStorageService.write('rommApiKey', _apiKeyController.text.trim(), prefs);
+
+ // Save Storage Config
+ if (_romsRoot != null) await prefs.setString('romsRootPath', _romsRoot!);
+ if (_emusRoot != null) await prefs.setString('emulatorsRootPath', _emusRoot!);
+
+ if (io.Platform.isLinux) {
+ await prefs.setString('linuxSyncPreset', _linuxPreset);
+ }
+
+ // Invalidate providers to trigger reload
+ ref.invalidate(rommConfigProvider);
+ ref.invalidate(rommServiceProvider);
+ ref.invalidate(directoryServiceProvider);
+ }
+
+ void _nextPage() {
+ if (_currentStep < _totalSteps - 1) {
+ _pageController.nextPage(
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ setState(() => _currentStep++);
+
+ if (_currentStep == 2) {
+ _initializeDefaultStorage();
+ }
+ } else {
+ _finishOnboarding();
+ }
+ }
+
+ void _prevPage() {
+ if (_currentStep > 0) {
+ _pageController.previousPage(
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ setState(() => _currentStep--);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: const Color(0xFF0f0f0f),
+ body: Container(
+ decoration: BoxDecoration(
+ gradient: RadialGradient(
+ center: Alignment.topLeft,
+ radius: 1.5,
+ colors: [
+ Colors.deepPurple.withValues(alpha: 0.15),
+ Colors.transparent,
+ ],
+ ),
+ ),
+ child: Column(
+ children: [
+ Expanded(
+ child: PageView(
+ controller: _pageController,
+ physics: const NeverScrollableScrollPhysics(),
+ children: [
+ _buildWelcomeStep(),
+ _buildServerStep(),
+ _buildStorageStep(),
+ _buildFinishStep(),
+ ],
+ ),
+ ),
+ _buildBottomBar(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildWelcomeStep() {
+ return Padding(
+ padding: const EdgeInsets.all(40.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Hero(
+ tag: 'logo',
+ child: Image.asset('freegosy_logo.png', height: 120),
+ ),
+ const SizedBox(height: 48),
+ const Text(
+ 'Welcome to Freegosy',
+ style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ 'The ultimate open-source cross-platform launcher for your self-hosted RomM library.',
+ style: TextStyle(fontSize: 18, color: Colors.grey),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 48),
+ _buildInfoCard(
+ icon: Icons.cloud_sync,
+ title: 'Cloud Save Sync',
+ subtitle: 'Sync your game saves across Windows, Linux, and macOS.',
+ ),
+ const SizedBox(height: 16),
+ _buildInfoCard(
+ icon: Icons.download_for_offline,
+ title: 'Automated Downloads',
+ subtitle: 'Fetch emulators and ROMs with a single click.',
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildServerStep() {
+ return SingleChildScrollView(
+ padding: const EdgeInsets.all(40.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Connect to RomM',
+ style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Enter your RomM server details to browse your library.',
+ style: TextStyle(color: Colors.grey),
+ ),
+ const SizedBox(height: 40),
+ TextField(
+ controller: _baseUrlController,
+ decoration: const InputDecoration(
+ labelText: 'Server URL',
+ hintText: 'http://your-ip:8080',
+ prefixIcon: Icon(Icons.dns),
+ ),
+ ),
+ const SizedBox(height: 20),
+ TextField(
+ controller: _apiKeyController,
+ decoration: const InputDecoration(
+ labelText: 'API Key',
+ hintText: 'Found in RomM User Settings',
+ prefixIcon: Icon(Icons.key),
+ ),
+ obscureText: true,
+ ),
+ const SizedBox(height: 32),
+ if (_testError != null)
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.red.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.error_outline, color: Colors.red),
+ const SizedBox(width: 12),
+ Expanded(child: Text(_testError!, style: const TextStyle(color: Colors.red))),
+ ],
+ ),
+ ),
+ if (_testSuccess)
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.green.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: const Row(
+ children: [
+ Icon(Icons.check_circle_outline, color: Colors.green),
+ SizedBox(width: 12),
+ Text('Connection Successful!', style: TextStyle(color: Colors.green)),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+ SizedBox(
+ width: double.infinity,
+ height: 50,
+ child: ElevatedButton(
+ onPressed: _isTesting ? null : _testConnection,
+ child: _isTesting
+ ? const CircularProgressIndicator(color: Colors.white)
+ : const Text('Test Connection'),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildStorageStep() {
+ return SingleChildScrollView(
+ padding: const EdgeInsets.all(40.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Storage Setup',
+ style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Where should we store your games and emulators?',
+ style: TextStyle(color: Colors.grey),
+ ),
+ const SizedBox(height: 40),
+
+ if (io.Platform.isLinux) ...[
+ const Text('Platform Preset', style: TextStyle(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 12),
+ _buildPresetOption(
+ id: 'default',
+ title: 'Manual / Native',
+ subtitle: 'Custom paths for everything.',
+ icon: Icons.folder,
+ ),
+ const SizedBox(height: 12),
+ _buildPresetOption(
+ id: 'emudeck',
+ title: 'EmuDeck',
+ subtitle: 'Standard Steam Deck layout.',
+ icon: Icons.sports_esports,
+ ),
+ const SizedBox(height: 12),
+ _buildPresetOption(
+ id: 'retrodeck',
+ title: 'RetroDeck',
+ subtitle: 'Flatpak-based all-in-one.',
+ icon: Icons.grid_view,
+ ),
+ const SizedBox(height: 32),
+ ],
+
+ _buildPathSelector(
+ label: 'ROMs Directory',
+ currentPath: _romsRoot ?? 'Loading...',
+ onTap: () async {
+ final path = await FilePicker.platform.getDirectoryPath();
+ if (path != null) setState(() => _romsRoot = path);
+ },
+ ),
+ const SizedBox(height: 24),
+ _buildPathSelector(
+ label: 'Emulators Directory',
+ currentPath: _emusRoot ?? 'Loading...',
+ onTap: () async {
+ final path = await FilePicker.platform.getDirectoryPath();
+ if (path != null) setState(() => _emusRoot = path);
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildFinishStep() {
+ return Padding(
+ padding: const EdgeInsets.all(40.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(Icons.rocket_launch, size: 80, color: Colors.deepPurple),
+ const SizedBox(height: 32),
+ const Text(
+ "You're all set!",
+ style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ 'Freegosy is ready to manage your library. You can always change these settings later.',
+ textAlign: TextAlign.center,
+ style: TextStyle(fontSize: 18, color: Colors.grey),
+ ),
+ const SizedBox(height: 48),
+ Container(
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.05),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: Colors.deepPurple.withValues(alpha: 0.3)),
+ ),
+ child: Column(
+ children: [
+ _buildSummaryRow('Server', _baseUrlController.text),
+ const Divider(height: 24),
+ _buildSummaryRow('ROMs', p.basename(_romsRoot ?? '')),
+ const Divider(height: 24),
+ _buildSummaryRow('Emulators', p.basename(_emusRoot ?? '')),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildBottomBar() {
+ return Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ if (_currentStep > 0)
+ TextButton(
+ onPressed: _prevPage,
+ child: const Text('Back'),
+ )
+ else
+ const SizedBox.shrink(),
+
+ Row(
+ children: List.generate(_totalSteps, (index) {
+ return Container(
+ width: 8,
+ height: 8,
+ margin: const EdgeInsets.symmetric(horizontal: 4),
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: _currentStep == index
+ ? Colors.deepPurple
+ : Colors.grey.withValues(alpha: 0.3),
+ ),
+ );
+ }),
+ ),
+
+ ElevatedButton(
+ onPressed: (_currentStep == 1 && !_testSuccess) ? null : _nextPage,
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ child: Text(_currentStep == _totalSteps - 1 ? 'Get Started' : 'Continue'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildInfoCard({required IconData icon, required String title, required String subtitle}) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.05),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ children: [
+ Icon(icon, size: 32, color: Colors.deepPurple),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
+ Text(subtitle, style: const TextStyle(color: Colors.grey, fontSize: 14)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPathSelector({required String label, required String currentPath, required VoidCallback onTap}) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 8),
+ InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(8),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.folder_open, color: Colors.grey),
+ const SizedBox(width: 12),
+ Expanded(child: Text(currentPath, overflow: TextOverflow.ellipsis)),
+ const Icon(Icons.edit, size: 16, color: Colors.deepPurple),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildPresetOption({required String id, required String title, required String subtitle, required IconData icon}) {
+ final isSelected = _linuxPreset == id;
+ return InkWell(
+ onTap: () => setState(() => _linuxPreset = id),
+ borderRadius: BorderRadius.circular(12),
+ child: Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: isSelected ? Colors.deepPurple.withValues(alpha: 0.1) : Colors.white.withValues(alpha: 0.05),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: isSelected ? Colors.deepPurple : Colors.transparent),
+ ),
+ child: Row(
+ children: [
+ Icon(icon, color: isSelected ? Colors.deepPurple : Colors.grey),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: TextStyle(fontWeight: FontWeight.bold, color: isSelected ? Colors.white : Colors.grey)),
+ Text(subtitle, style: const TextStyle(fontSize: 12, color: Colors.grey)),
+ ],
+ ),
+ ),
+ if (isSelected) const Icon(Icons.check_circle, color: Colors.deepPurple),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildSummaryRow(String label, String value) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(label, style: const TextStyle(color: Colors.grey)),
+ Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
+ ],
+ );
+ }
+}
diff --git a/lib/ui/widgets/save_conflict_dialog.dart b/lib/ui/widgets/save_conflict_dialog.dart
new file mode 100644
index 0000000..dc9b73c
--- /dev/null
+++ b/lib/ui/widgets/save_conflict_dialog.dart
@@ -0,0 +1,123 @@
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import '../../core/save/save_sync_service.dart';
+
+class SaveConflictDialog extends StatelessWidget {
+ final SaveConflictException conflict;
+
+ const SaveConflictDialog({super.key, required this.conflict});
+
+ @override
+ Widget build(BuildContext context) {
+ final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
+
+ return AlertDialog(
+ title: const Row(
+ children: [
+ Icon(Icons.warning_amber_rounded, color: Colors.orange),
+ SizedBox(width: 12),
+ Text('Sync Conflict Detected'),
+ ],
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Both local and cloud saves have been modified for ${conflict.game.name}. Please choose which version to keep.',
+ style: const TextStyle(fontSize: 14),
+ ),
+ const SizedBox(height: 24),
+ _buildOption(
+ context,
+ title: 'Use Local Version',
+ time: conflict.localTime,
+ dateFormat: dateFormat,
+ icon: Icons.computer,
+ onTap: () => Navigator.pop(context, 'local'),
+ isNewer: conflict.localTime.isAfter(conflict.cloudTime),
+ ),
+ const SizedBox(height: 12),
+ _buildOption(
+ context,
+ title: 'Use Cloud Version',
+ time: conflict.cloudTime,
+ dateFormat: dateFormat,
+ icon: Icons.cloud_outlined,
+ onTap: () => Navigator.pop(context, 'cloud'),
+ isNewer: conflict.cloudTime.isAfter(conflict.localTime),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, null),
+ child: const Text('Cancel Sync'),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildOption(
+ BuildContext context, {
+ required String title,
+ required DateTime time,
+ required DateFormat dateFormat,
+ required IconData icon,
+ required VoidCallback onTap,
+ required bool isNewer,
+ }) {
+ return InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
+ child: Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: isNewer ? Colors.deepPurple : Colors.grey.withValues(alpha: 0.3),
+ width: isNewer ? 2 : 1,
+ ),
+ borderRadius: BorderRadius.circular(12),
+ color: isNewer ? Colors.deepPurple.withValues(alpha: 0.05) : null,
+ ),
+ child: Row(
+ children: [
+ Icon(icon, color: isNewer ? Colors.deepPurple : Colors.grey),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
+ if (isNewer) ...[
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.green,
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: const Text(
+ 'NEWER',
+ style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white),
+ ),
+ ),
+ ],
+ ],
+ ),
+ Text(
+ 'Modified: ${dateFormat.format(time)}',
+ style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
+ ),
+ ],
+ ),
+ ),
+ const Icon(Icons.chevron_right, color: Colors.grey),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 4b7a492..1b90469 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -504,6 +504,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.10.1"
+ intl:
+ dependency: "direct main"
+ description:
+ name: intl
+ sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.20.2"
io:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 17b3d7c..3726938 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -53,6 +53,7 @@ dependencies:
hive_flutter: ^1.1.0
synchronized: ^3.1.0+1
uuid: ^4.5.1
+ intl: ^0.20.2
dev_dependencies:
flutter_test:
diff --git a/test/unit/save_sync_service_test.dart b/test/unit/save_sync_service_test.dart
index da37499..7904779 100644
--- a/test/unit/save_sync_service_test.dart
+++ b/test/unit/save_sync_service_test.dart
@@ -37,6 +37,7 @@ void main() {
.thenAnswer((_) async => sysTemp);
final prefs = await SharedPreferences.getInstance();
+ when(mockRommService.getLatestSave(any)).thenAnswer((_) async => null);
service = SaveSyncService(mockRommService, mockDirectoryService, mockStrategyRegistry, prefs);
});
@@ -50,8 +51,6 @@ void main() {
test('pushSaves() uploads when local hash differs', () async {
final tempDir = await Directory.systemTemp.createTemp('save_sync_test');
- // Use mgba strategy (default for gba slug in SaveSyncService)
- // It looks for .sav next to ROM
final romPath = p.join(tempDir.path, 'game.gba');
final saveFile = File(p.join(tempDir.path, 'game.sav'));
await saveFile.writeAsString('new content');
@@ -65,12 +64,18 @@ void main() {
screenshotFile: anyNamed('screenshotFile'),
overrideFilename: anyNamed('overrideFilename')
)).thenAnswer((_) async => true);
- when(mockRommService.pruneOldSaves(any)).thenAnswer((_) async => {});
+ when(mockRommService.pruneOldSaves(any, keepCount: anyNamed('keepCount'))).thenAnswer((_) async {});
final ok = await service.pushSaves(game, romPath);
expect(ok, isTrue, reason: 'Should have found and uploaded game.sav');
- verify(mockRommService.uploadSave('game1', any, slot: anyNamed('slot'), screenshotFile: anyNamed('screenshotFile'), overrideFilename: anyNamed('overrideFilename'))).called(1);
+ verify(mockRommService.uploadSave(
+ 'game1',
+ any,
+ slot: anyNamed('slot'),
+ screenshotFile: anyNamed('screenshotFile'),
+ overrideFilename: anyNamed('overrideFilename')
+ )).called(1);
await tempDir.delete(recursive: true);
});
@@ -83,7 +88,6 @@ void main() {
final game = Game(id: 'game1', name: 'game', platformSlug: 'gba', fileSize: 0);
- // Mock upload to be sure it's called first time
when(mockRommService.uploadSave(
any,
any,
@@ -91,18 +95,56 @@ void main() {
screenshotFile: anyNamed('screenshotFile'),
overrideFilename: anyNamed('overrideFilename')
)).thenAnswer((_) async => true);
- when(mockRommService.pruneOldSaves(any)).thenAnswer((_) async => {});
+ when(mockRommService.pruneOldSaves(any, keepCount: anyNamed('keepCount'))).thenAnswer((_) async {});
await service.pushSaves(game, romPath);
- verify(mockRommService.uploadSave('game1', any, slot: anyNamed('slot'), screenshotFile: anyNamed('screenshotFile'), overrideFilename: anyNamed('overrideFilename'))).called(1);
+ verify(mockRommService.uploadSave(
+ 'game1',
+ any,
+ slot: anyNamed('slot'),
+ screenshotFile: anyNamed('screenshotFile'),
+ overrideFilename: anyNamed('overrideFilename')
+ )).called(1);
// Second time should skip
clearInteractions(mockRommService);
+ // We must re-stub because clearInteractions might affect stubs depending on implementation,
+ // though usually it only clears call history. But to be safe:
+ when(mockRommService.getLatestSave(any)).thenAnswer((_) async => null);
+
final ok = await service.pushSaves(game, romPath);
expect(ok, isTrue, reason: 'Should return true (success) even if skipping due to matching hash');
verifyNever(mockRommService.uploadSave(any, any));
await tempDir.delete(recursive: true);
});
+
+ test('pushSaves() throws SaveConflictException when remote is newer than last pull', () async {
+ final tempDir = await Directory.systemTemp.createTemp('save_sync_test_conflict');
+ final romPath = p.join(tempDir.path, 'game.gba');
+ final saveFile = File(p.join(tempDir.path, 'game.sav'));
+ await saveFile.writeAsString('local change');
+
+ final game = Game(id: 'game1', name: 'game', platformSlug: 'gba', fileSize: 0);
+
+ // Setup a last pull time (1 hour ago)
+ final prefs = await SharedPreferences.getInstance();
+ final lastPull = DateTime.now().subtract(const Duration(hours: 1));
+ await prefs.setString('last_pull_game1', lastPull.toIso8601String());
+
+ // Mock remote to be NEWER than last pull (30 mins ago)
+ final remoteTime = DateTime.now().subtract(const Duration(minutes: 30));
+ when(mockRommService.getLatestSave('game1')).thenAnswer((_) async => {
+ 'updated_at': remoteTime.toIso8601String(),
+ 'screenshot_url': 'http://remote-screenshot.png',
+ });
+
+ await expectLater(
+ service.pushSaves(game, romPath),
+ throwsA(isA<SaveConflictException>()),
+ );
+
+ await tempDir.delete(recursive: true);
+ });
});
}