Skip to content

commit 8c1616d

abduznik edited this page May 23, 2026 · 1 revision

feat: implement onboarding wizard and save conflict resolution system

Commit: 8c1616db57b73a3a0a9c01cc85b467e47bfe64cb

Author: abduznik

Date: 2026-04-28

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

Files Changed

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.dart
  • lib/core/save/save_sync_service.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/library_actions.dart
  • lib/ui/screens/onboarding_screen.dart
  • lib/ui/widgets/save_conflict_dialog.dart
  • pubspec.lock
  • pubspec.yaml
  • test/unit/save_sync_service_test.dart

Diff

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);
+    });
   });
 }

Clone this wiki locally