-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 3850059
Commit: 38500598998820b876088392ccb52630d95e3e38
Author: abduznik
Date: 2026-03-26
-
perf: cache images, fix extraction service, emulator downloads in download tab
-
feat: extraction service, emulator downloads in download tab, image caching, performance optimizations, emulator conflict resolver, melonDS, Flycast, platform guards
-
feat: conflict resolver grouping, Flycast asset fix and extended platform support
-
feat: PPSSPP, mGBA, MAME standalones, RetroArch auto core install, MAME self-extracting exe support
-
docs: update agent.md with new emulators, extraction service, conflict resolver, performance improvements
-
feat: save strategies for DuckStation/melonDS/mGBA/PPSSPP/Cemu, fix syncMode scope to RetroArch only, emulator path overrides, Cemu save as zip
-
feat: save strategies for DuckStation/melonDS/mGBA/PPSSPP/Cemu, emulator path overrides, conflict resolver drives save sync, launch status snackbars, Cemu save zip fix, backupSave chain fix, lint cleanup, agent.md update
-
fix: Cemu save restore path extraction, preserve full zip structure
-
feat: RetroArch auto core download, MissingRetroArchCoreException, downloadCore from buildbot
-
refactor: split settings_screen into display and emulators sections, fix analyze warnings
-
fix: PSP save sync, strategy preferences load before first sync, SAVEDATA zipped as single file
-
fix: PSP saves zip correctly, directory exists check for File vs Directory, preferences await on startup
-
feat: auto-sync saves on game close, fix PSP SAVEDATA restore path, fix cold start null strategy on first launch
-
fix: melonDS launch pipe drain fix, PPSSPP save restore path, auto-sync on game close, PSP empty folder check
-
fix: analyze warnings, BuildContext async gaps, unused imports
-
fix: ZIP magic byte detection for extensionless multi-file ROMs, extraction on background isolate, remove debug prints
Why: Adds a new feature or capability to the application.
README.md | 92 ++--
agent.md | 285 +++++-----
lib/core/downloader/download_service.dart | 39 +-
lib/core/emulator/emulator_download_service.dart | 27 +-
lib/core/emulator/emulator_registry_data.dart | 78 ++-
lib/core/emulator/emulator_strategy.dart | 4 +-
lib/core/emulator/strategies/azahar_strategy.dart | 9 +
lib/core/emulator/strategies/cemu_strategy.dart | 9 +
lib/core/emulator/strategies/dolphin_strategy.dart | 7 +
.../emulator/strategies/duckstation_strategy.dart | 9 +
lib/core/emulator/strategies/eden_strategy.dart | 7 +
lib/core/emulator/strategies/flycast_strategy.dart | 49 ++
lib/core/emulator/strategies/mame_strategy.dart | 69 +++
lib/core/emulator/strategies/melonds_strategy.dart | 52 ++
lib/core/emulator/strategies/mgba_strategy.dart | 49 ++
lib/core/emulator/strategies/pcsx2_strategy.dart | 11 +
lib/core/emulator/strategies/ppsspp_strategy.dart | 49 ++
.../emulator/strategies/retroarch_strategy.dart | 93 +++-
lib/core/emulator/strategies/rpcs3_strategy.dart | 9 +
lib/core/emulator/strategies/xemu_strategy.dart | 9 +
lib/core/emulator/strategies/xenia_strategy.dart | 9 +
lib/core/emulator/strategy_registry.dart | 119 ++++-
lib/core/extraction/extraction_service.dart | 70 +++
lib/core/save/save_strategy.dart | 2 +-
lib/core/save/save_sync_service.dart | 72 ++-
lib/core/save/strategies/cemu_save_strategy.dart | 69 +++
.../save/strategies/duckstation_save_strategy.dart | 122 +++++
.../save/strategies/melonds_save_strategy.dart | 68 +++
lib/core/save/strategies/mgba_save_strategy.dart | 68 +++
lib/core/save/strategies/pcsx2_save_strategy.dart | 42 +-
lib/core/save/strategies/ppsspp_save_strategy.dart | 108 ++++
.../save/strategies/retroarch_save_strategy.dart | 110 ++--
lib/core/storage/directory_service.dart | 40 +-
lib/providers/download_provider.dart | 31 +-
lib/providers/library_provider.dart | 2 -
lib/providers/romm_provider.dart | 11 +-
lib/ui/screens/library_screen.dart | 571 ++++++++++-----------
lib/ui/screens/library_skeleton.dart | 91 ++++
lib/ui/screens/settings_display_section.dart | 165 ++++++
lib/ui/screens/settings_emulators_section.dart | 158 ++++++
lib/ui/screens/settings_screen.dart | 344 +++----------
lib/ui/widgets/game_card.dart | 179 ++++---
macos/Flutter/GeneratedPluginRegistrant.swift | 2 +
pubspec.lock | 112 ++++
pubspec.yaml | 1 +
45 files changed, 2582 insertions(+), 940 deletions(-)
README.mdagent.mdlib/core/downloader/download_service.dartlib/core/emulator/emulator_download_service.dartlib/core/emulator/emulator_registry_data.dartlib/core/emulator/emulator_strategy.dartlib/core/emulator/strategies/azahar_strategy.dartlib/core/emulator/strategies/cemu_strategy.dartlib/core/emulator/strategies/dolphin_strategy.dartlib/core/emulator/strategies/duckstation_strategy.dartlib/core/emulator/strategies/eden_strategy.dartlib/core/emulator/strategies/flycast_strategy.dartlib/core/emulator/strategies/mame_strategy.dartlib/core/emulator/strategies/melonds_strategy.dartlib/core/emulator/strategies/mgba_strategy.dartlib/core/emulator/strategies/pcsx2_strategy.dartlib/core/emulator/strategies/ppsspp_strategy.dartlib/core/emulator/strategies/retroarch_strategy.dartlib/core/emulator/strategies/rpcs3_strategy.dartlib/core/emulator/strategies/xemu_strategy.dartlib/core/emulator/strategies/xenia_strategy.dartlib/core/emulator/strategy_registry.dartlib/core/extraction/extraction_service.dartlib/core/save/save_strategy.dartlib/core/save/save_sync_service.dartlib/core/save/strategies/cemu_save_strategy.dartlib/core/save/strategies/duckstation_save_strategy.dartlib/core/save/strategies/melonds_save_strategy.dartlib/core/save/strategies/mgba_save_strategy.dartlib/core/save/strategies/pcsx2_save_strategy.dartlib/core/save/strategies/ppsspp_save_strategy.dartlib/core/save/strategies/retroarch_save_strategy.dartlib/core/storage/directory_service.dartlib/providers/download_provider.dartlib/providers/library_provider.dartlib/providers/romm_provider.dartlib/ui/screens/library_screen.dartlib/ui/screens/library_skeleton.dartlib/ui/screens/settings_display_section.dartlib/ui/screens/settings_emulators_section.dartlib/ui/screens/settings_screen.dartlib/ui/widgets/game_card.dartmacos/Flutter/GeneratedPluginRegistrant.swiftpubspec.lockpubspec.yaml
diff --git a/README.md b/README.md
index c8a824d..0d041c4 100644
--- a/README.md
+++ b/README.md
@@ -2,32 +2,46 @@
A cross-platform Flutter app for browsing your RomM library, downloading ROMs, and launching games directly in emulators—all from one intuitive interface.
-## What's New in 0.0.2
+## What's New in 0.1.0
-### Full Windows Native Game Support
-- Download Windows games (.zip and .7z) with automatic extraction
-- Auto-detection of game executables in extracted folders
-- Manual exe and save path configuration via long-press on any Windows game card
-- Crash detection on launch — if a game exits immediately, a clear error message explains likely causes (missing DirectX, Visual C++ redistributables, etc.)
-- Save sync for Windows games via PCGamingWiki automatic save path detection, with manual override support
-- All exe and save path overrides persist across app restarts
+### New Emulator Support
+- **Flycast** (Dreamcast, Naomi, Atomiswave)
+- **melonDS** (Nintendo DS)
+- **PPSSPP** standalone (PlayStation Portable)
+- **mGBA** standalone (Game Boy Advance, GBC, GB)
+- **MAME** (Arcade)
### Expanded Save Sync
-- **PCSX2 (PS2)**: Syncs memory cards (Mcd001.ps2, Mcd002.ps2) and save states
-- **RPCS3 (PS3)**: Syncs save data folders by title ID
-- **Xenia Canary (Xbox 360)**: Syncs content save folders by title ID
-- **Windows games**: Full save directory packaging into zip for upload, auto-extraction on restore
-- All multi-file save strategies package saves as a single zip for clean cloud storage
-
-### 7-Zip Support
-- Bundled 7zr.exe for .7z extraction — no manual installation required
-- Automatically extracted to AppData on first run
-- Full .7z support for Windows game downloads
-
-### Quality of Life
-- Launch error messages now display for 8 seconds for better readability
-- Stale exe overrides (pointing to moved/deleted files) are automatically discarded and re-detected
-- ROM detection improved for Windows games — returns game folder instead of individual file
+- **DuckStation** (PS1): Memory card save sync
+- **melonDS** (NDS): Save file sync
+- **mGBA** (GBA/GBC/GB): Save file sync
+- **PPSSPP** (PSP): Entire SAVEDATA directory with automatic folder structure restoration
+- **Cemu** (Wii U): Full mlc01/usr/save/ directory packaging and restore
+- All new strategies support both push (upload) and pull (download) operations
+
+### Auto-Sync on Game Close
+- Saves automatically push to cloud when you close the emulator
+- Available for all supported emulators via `launchWithHandle()`
+- Seamless background operation—no manual intervention needed
+- Works alongside pre-launch save push and manual sync
+
+### Smart Emulator Management
+- **Conflict Resolver**: When multiple emulators support the same platform, choose which one to use in Settings
+- **Path Overrides**: Point Freegosy to existing emulator installations via Settings folder icon (no need to re-download)
+- **Preference Persistence**: Your emulator choices and custom paths are saved across app restarts
+
+### RetroArch Core Auto-Download
+- Missing cores are detected at launch time
+- User is offered to auto-download and install the required core
+- Full core list support via RetroArch buildbot
+
+### Performance & UX Enhancements
+- Library grid renders without blocking—0% CPU while idle
+- Cached images (memCache) for fast card rendering
+- Optimized grid with extended cache extent and dual-stem state file matching for RetroArch
+- **Library Display Presets**: Windows, Steam Deck, Cozy, Compact—quick-switch your layout
+- **Launch Status Snackbars**: Real-time feedback showing "Pushing saves / Syncing saves / Launching / Auto-syncing" at each stage
+- **Downloads Tab**: Emulator downloads now show alongside game downloads with progress tracking
## Currently Working
@@ -45,39 +59,47 @@ A cross-platform Flutter app for browsing your RomM library, downloading ROMs, a
- Cemu (Wii U)
- Xemu (Xbox)
- Xenia Canary (Xbox 360)
+ - Flycast (Dreamcast, Naomi, Atomiswave)
+ - melonDS (Nintendo DS)
+ - PPSSPP (PlayStation Portable)
+ - mGBA (Game Boy Advance/Color/Game Boy)
+ - MAME (Arcade)
- Windows Native (PC games)
- **Emulator Downloads**: One-tap emulator download and installation from Settings
-- **Save Sync**: Two-way save synchronization with RomM cloud for:
+- **Save Sync**: Bidirectional save synchronization with RomM cloud for:
- RetroArch (all supported platforms)
- Dolphin (GameCube/Wii)
- Eden (Switch)
- PCSX2 (PS2)
- RPCS3 (PS3)
+ - DuckStation (PS1)
+ - melonDS (NDS)
+ - mGBA (GBA/GBC/GB)
+ - PPSSPP (PSP)
+ - Cemu (Wii U)
- Xenia Canary (Xbox 360)
- Windows native games (via PCGamingWiki auto-detection)
## Roadmap
-### Near Term
+### In Progress
+- **Linux/macOS support** — Platform detection and path resolution already structured; adding platform-specific executable paths and environment variable resolution
-- **Cemu save sync** — Wii U saves at mlc01/usr/save/
-- **Azahar save sync** — 3DS saves via SDMC folder
-- **MelonDS support** — better NDS emulation alternative
-- **macOS & Linux support** — platform detection and path resolution already structured for future expansion
+### Near Term
+- **Auto-update emulators** — Keep emulators fresh without manual downloads
+- **Android support** — Deep links to app stores for Play Store/Epic Games/etc.
+- **Recently played / play time tracking** — See your gaming stats at a glance
### End-Game Features
-
-- Automatic emulator updates
- Custom ROM platform tagging
- Mobile companion app for on-the-go library browsing
### Cross-Platform Vision
-
-Freegosy is designed as a truly cross-platform experience. The codebase is structured to support Windows, macOS, and Linux with platform-specific code isolated behind strategy patterns and service abstractions.
+Freegosy is designed as a truly cross-platform experience. The codebase is structured to support Windows, macOS, Linux, and Android with platform-specific code isolated behind strategy patterns and service abstractions.
## Status
-Actively under development. Release 0.0.2 focuses on Windows game support and expanded save sync coverage.
+Actively under development. Release 0.1.0 brings major emulator expansion, comprehensive save sync, and auto-sync on game close.
## About RomM
@@ -85,4 +107,4 @@ Freegosy is built to complement [RomM](https://github.com/rommapp/romm), a moder
## Contributing
-Check out `agent.md` for the full file map, coding rules, and contracts for adding new emulators, save strategies, or features.
\ No newline at end of file
+Check out `agent.md` for the full file map, coding rules, and contracts for adding new emulators, save strategies, or features.
diff --git a/agent.md b/agent.md
index de0be33..183080a 100644
--- a/agent.md
+++ b/agent.md
@@ -1,135 +1,152 @@
-# Freegosy — Agent Map
-> This file is the source of truth for all LLM agents (Claude, Gemini) working on this codebase.
-> Read this before touching any file. Update this file if you create or split any file.
-
-## Project Overview
-Freegosy is a cross-platform Flutter app for browsing a RomM library, downloading ROMs via HTTP, and launching emulators. Built with Riverpod for state management.
-
-## Rules (MANDATORY)
-- No file exceeds 600 lines. If adding code would exceed this, split the file first and update this map.
-- All RomM API calls go through `romm_service.dart` only. Never call the API directly from UI or providers.
-- All emulator logic goes through the strategy pattern. Never hardcode emulator behavior in UI.
-- New emulator = new file in `core/emulator/strategies/`, register in `strategy_registry.dart` only.
-- New save strategy = new file in `core/save/strategies/`, register in `save_sync_service.dart` only.
-- New screen = new file in `ui/screens/`.
-- New reusable widget = new file in `ui/widgets/`.
-- Providers are thin — they call services, they do not contain business logic.
-- Never use `Platform.environment` or `Platform.isWindows` directly — causes conflicts with flutter/foundation.dart. Use `Process.run('cmd', ['/c', 'echo %APPDATA%'])` for env vars, and `defaultTargetPlatform == TargetPlatform.windows` for platform checks.
-
-## File Map
-
-### Entry Points
-- `lib/main.dart` — App entry point. Initializes Riverpod ProviderScope. Calls app.dart.
-- `lib/app.dart` — MaterialApp setup, theme, initial route, navigation shell.
-
-### Core — RomM
-- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getSaves(), uploadSave(), getLatestSave(), downloadSave(). Returns typed models.
-- `lib/core/romm/romm_models.dart` — Data models: Game, Platform, SaveFile, RomMConfig.
-
-### Core — Save Sync
-- `lib/core/save/save_strategy.dart` — Abstract base class SaveStrategy. Methods: getSaveDir(), getSaveFiles(), restoreSave(). Helpers: backupSave() (3-version rotation), getRomStem().
-- `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). Wires all strategies to RommService. Exposes windowsSaveStrategy for external access.
-- `lib/core/save/strategies/retroarch_save_strategy.dart` — RetroArch save strategy (GBA/GBC/GB/SNES/NES/N64/NDS/PSX/PSP/Dreamcast/Megadrive). Reads saves/{coreName}/{stem}.srm and .state.auto.
-- `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci.
-- `lib/core/save/strategies/eden_save_strategy.dart` — Eden/Switch save strategy. Resolves title ID via filename regex, XCI header parse, or save folder scan. Zips save folder for upload.
-- `lib/core/save/strategies/windows_save_strategy.dart` — Windows native game save strategy. Uses PCGamingWiki API for auto-detection, supports manual override. Zips entire save directory for upload, extracts on restore. Persists overrides via SharedPreferences (prefix `win_save_`).
-- `lib/core/save/strategies/pcsx2_save_strategy.dart` — PCSX2 save strategy (PS2). Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2. States: {emulatorDir}/sstates/{stem}.000 etc.
-- `lib/core/save/strategies/rpcs3_save_strategy.dart` — RPCS3 save strategy (PS3). Saves at %APPDATA%\rpcs3\dev_hdd0\home\00000001\savedata\{titleId}\. Extracts title ID via regex [A-Z]{4}\d{5}. Zips save folder for upload. Uses Process.run for APPDATA resolution.
-- `lib/core/save/strategies/xenia_save_strategy.dart` — Xenia Canary save strategy (Xbox 360). Saves at {emulatorDir}\content\{titleId}\00000001\. Extracts title ID via 8-char hex regex. Zips save folder for upload.
-
-### Core — Emulator
-- `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable. Methods: launch(Game, romPath), resolveSavePath(Game), getExecutableForPlatform().
-- `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions.
-- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Methods: getStrategyForSlug(), getDefinition().
-- `lib/core/emulator/emulator_download_service.dart` — Service for downloading and extracting emulators. Supports direct URL and GitHub release types. Handles .zip and .7z extraction.
-- `lib/core/emulator/github_release_service.dart` — Fetches latest release asset URL from GitHub API with required/excluded name filters.
-- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy. Slugs: gba/gbc/gb/nes/snes/n64/nds/psx/ps1/psp/dc/dreamcast/megadrive/genesis/md etc.
-- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy. Slugs: gc/gamecube/wii/ngc.
-- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy. Slugs: switch/nintendo-switch/ns.
-- `lib/core/emulator/strategies/rpcs3_strategy.dart` — RPCS3 strategy. Slugs: ps3/playstation-3/playstation3.
-- `lib/core/emulator/strategies/pcsx2_strategy.dart` — PCSX2 strategy. Slugs: ps2/playstation-2/playstation2.
-- `lib/core/emulator/strategies/azahar_strategy.dart` — Azahar strategy. Slugs: 3ds/n3ds/nintendo-3ds/nintendo3ds/new-nintendo-3ds/new-nintendo-3ds-xl.
-- `lib/core/emulator/strategies/cemu_strategy.dart` — Cemu strategy. Slugs: wiiu/wii-u/nintendo-wii-u/nintendo-wiiu.
-- `lib/core/emulator/strategies/duckstation_strategy.dart` — DuckStation strategy. Slugs: ps1/playstation/psx.
-- `lib/core/emulator/strategies/xemu_strategy.dart` — Xemu strategy. Slugs: xbox.
-- `lib/core/emulator/strategies/xenia_strategy.dart` — Xenia Canary strategy. Slugs: xbox360/xbla.
-- `lib/core/emulator/strategies/windows_strategy.dart` — Windows native game strategy. Auto-detects exe in game folder, validates stored override exists on disk before using. Launches via Process.start. Monitors exit code for 5s — throws if crashed. Persists overrides via SharedPreferences (prefix `win_exe_`).
-
-### Core — Downloader
-- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI. Handles .zip extraction via archive package and .7z via bundled 7zr.exe (resolved via DirectoryService.resolveSevenZipPath()). Windows games (.zip/.7z) always extracted regardless of isMultiFile flag.
-
-### Core — Storage
-- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. Persists paths via SharedPreferences. resolveSevenZipPath() extracts bundled 7zr.exe from Flutter assets to %APPDATA%\Freegosy\thirdparty\ on first run. Uses defaultTargetPlatform for Windows check, Process.run for APPDATA resolution.
-
-### Core — Windows
-- `lib/core/windows/windows_game_service.dart` — Finds main exe in game folder (hint match then largest). Skips uninstall/setup/redist/etc. Launches via Process.start detached.
-- `lib/core/windows/pcgamingwiki_service.dart` — Queries PCGamingWiki API for Windows game save locations. Parses MediaWiki markup, expands environment variables (APPDATA, LOCALAPPDATA etc), returns resolved paths.
-
-### Core — Updater
-- `lib/core/updater/updater_service.dart` — Checks GitHub Releases API for new version. Downloads new binary to temp, swaps, relaunches.
-
-### Providers
-- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry (loads WindowsStrategy persisted overrides on init), SaveSyncService (loads WindowsSaveStrategy persisted overrides on init).
-- `lib/providers/library_provider.dart` — Riverpod providers for platforms list and games list. Includes search, filtering, card aspect ratio, and RetroArch sync mode persistence.
-- `lib/providers/download_provider.dart` — Riverpod providers for active downloads and progress.
-
-### UI — Screens
-- `lib/ui/screens/library_screen.dart` — Main screen. Game grid with search, platform filter, download, launch, save sync. Windows games support long-press to open config dialog and auto-show config on missing exe. Launch errors show for 8 seconds.
-- `lib/ui/screens/download_screen.dart` — Active downloads list with progress bars.
-- `lib/ui/screens/settings_screen.dart` — RomM server config, card aspect ratio, storage paths, RetroArch sync mode, emulator download/install status.
-
-### UI — Widgets
-- `lib/ui/widgets/game_card.dart` — Single game tile. Shows cover, name, download/launch/sync buttons. Green dot when downloaded.
-- `lib/ui/widgets/download_progress_card.dart` — Single download row with progress bar and cancel button.
-- `lib/ui/widgets/platform_filter_bar.dart` — Horizontal scrollable platform chip row with distinct styling for selected/unselected states.
-- `lib/ui/widgets/windows_game_config_dialog.dart` — Dialog for configuring Windows game exe path and save directory. Browse buttons for both. Returns `Map<String, String>` with keys `exe` and `save`. Only shown for Windows platform games.
-
-## Key Contracts
-
-### EmulatorStrategy (never change these signatures)
-```dart
-abstract class EmulatorStrategy {
- String get name;
- String get emulatorId;
- List<String> get supportedSlugs;
- String get windowsExecutable;
- String get linuxExecutable;
- String getExecutableForPlatform();
- Future<void> launch(Game game, String romPath);
- String resolveSavePath(Game game);
- bool get supportsSaveSync;
-}
-```
-
-### DownloadProgress
-```dart
-class DownloadProgress {
- final String id; // e.g., game ID or emulator ID
- final String gameName; // display name (game.name or emulator name)
- final double percent;
- final int bytesReceived;
- final int totalBytes;
- final bool isComplete;
- final String? error;
-}
-```
-
-### RomMConfig
-```dart
-class RomMConfig {
- final String baseUrl;
- final String username;
- final String password;
-}
-```
-
-## Dependencies (pubspec.yaml)
-- `flutter_riverpod` — state management
-- `dio` — HTTP client for API calls and downloads
-- `path_provider` — platform-safe file paths
-- `shared_preferences` — persist RomM config, card ratio, sync mode, Windows exe/save overrides
-- `package_info_plus` — read current app version for updater
-- `archive` — zip extraction and creation (ZipDecoder, ZipFileEncoder)
-- `file_picker` — directory and file selection
-- `path` — path manipulation utilities
+# Freegosy — Agent Map
+> This file is the source of truth for all LLM agents (Claude, Gemini) working on this codebase.
+> Read this before touching any file. Update this file if you create or split any file.
+
+## Project Overview
+Freegosy is a cross-platform Flutter app for browsing a RomM library, downloading ROMs via HTTP, and launching emulators. Built with Riverpod for state management.
+
+## Rules (MANDATORY)
+- No file exceeds 600 lines. If adding code would exceed this, split the file first and update this map.
+- All RomM API calls go through `romm_service.dart` only. Never call the API directly from UI or providers.
+- All emulator logic goes through the strategy pattern. Never hardcode emulator behavior in UI.
+- New emulator = new file in `core/emulator/strategies/`, register in `strategy_registry.dart` only.
+- New save strategy = new file in `core/save/strategies/`, register in `save_sync_service.dart` only.
+- New screen = new file in `ui/screens/`.
+- New reusable widget = new file in `ui/widgets/`.
+- Providers are thin — they call services, they do not contain business logic.
+- Never use `Platform.environment` or `Platform.isWindows` directly — causes conflicts with flutter/foundation.dart. Use `Process.run('cmd', ['/c', 'echo %APPDATA%'])` for env vars, and `defaultTargetPlatform == TargetPlatform.windows` for platform checks.
+
+## File Map
+
+### Entry Points
+- `lib/main.dart` — App entry point. Initializes Riverpod ProviderScope. Calls app.dart.
+- `lib/app.dart` — MaterialApp setup, theme, initial route, navigation shell.
+
+### Core — RomM
+- `lib/core/romm/romm_service.dart` — All RomM HTTP calls (Dio). Methods: getPlatforms(), getGames(), getSaves(), uploadSave(), getLatestSave(), downloadSave(). Returns typed models.
+- `lib/core/romm/romm_models.dart` — Data models: Game, Platform, SaveFile, RomMConfig.
+
+### Core — Save Sync
+- `lib/core/save/save_strategy.dart` — Abstract base class SaveStrategy. Methods: getSaveDir(), getSaveFiles(), restoreSave(). Helpers: backupSave() keeps max 3 clean versions (.bak, .bak1, .bak2) — never creates chained .bak.bak files., getRomStem().
+- `lib/core/save/save_sync_service.dart` — SaveSyncService. Methods: pushSaves(), pullSave(), getStrategyForSlug(). getStrategyForSlug() checks StrategyRegistry user preferences first before falling back to platform slug defaults. Accepts StrategyRegistry as constructor parameter. Wires all strategies to RommService. Exposes windowsSaveStrategy for external access.
+- `lib/core/save/strategies/retroarch_save_strategy.dart` — RetroArch save strategy (GBA/GBC/GB/SNES/NES/N64/NDS/PSX/PSP/Dreamcast/Megadrive). Reads saves/{coreName}/{stem}.srm and .state.auto. PSP special case returns entire saves/PPSSPP/PSP directory as zip. All platforms use dual-stem (getRomStem + romPath filename) for state file matching. Directory existence check handles both File and Directory types.
+- `lib/core/save/strategies/dolphin_save_strategy.dart` — Dolphin save strategy (GC/Wii). Reads User/GC/{region}/Card A/*.gci.
+- `lib/core/save/strategies/eden_save_strategy.dart` — Eden/Switch save strategy. Resolves title ID via filename regex, XCI header parse, or save folder scan. Zips save folder for upload.
+- `lib/core/save/strategies/windows_save_strategy.dart` — Windows native game save strategy. Uses PCGamingWiki API for auto-detection, supports manual override. Zips entire save directory for upload, extracts on restore. Persists overrides via SharedPreferences (prefix `win_save_`).
+- `lib/core/save/strategies/pcsx2_save_strategy.dart` — PCSX2 save strategy (PS2). Memcards: {emulatorDir}/memcards/Mcd001.ps2, Mcd002.ps2. States: {emulatorDir}/sstates/{stem}.000 etc.
+- `lib/core/save/strategies/rpcs3_save_strategy.dart` — RPCS3 save strategy (PS3). Saves at %APPDATA%
pcs3\dev_hdd0\home\00000001\savedata\{titleId}\. Extracts title ID via regex [A-Z]{4}\d{5}. Zips save folder for upload. Uses Process.run for APPDATA resolution.
+- `lib/core/save/strategies/xenia_save_strategy.dart` — Xenia Canary save strategy (Xbox 360). Saves at {emulatorDir}\content\{titleId}\00000001\. Extracts title ID via 8-char hex regex. Zips save folder for upload.
+- `lib/core/save/strategies/duckstation_save_strategy.dart` — DuckStation save strategy (PS1). Checks for portable.txt in emulator dir — if present uses {emulatorDir}/memcards/{stem}.mcd, otherwise falls back to %LOCALAPPDATA%\DuckStation\memcards\{stem}.mcd.
+- `lib/core/save/strategies/melonds_save_strategy.dart` — melonDS save strategy (NDS). Saves .sav file next to ROM, derived from actual romPath filename not game name.
+- `lib/core/save/strategies/mgba_save_strategy.dart` — mGBA save strategy (GBA/GBC/GB). Saves .sav file next to ROM, derived from actual romPath filename.
+- `lib/core/save/strategies/ppsspp_save_strategy.dart` — PPSSPP save strategy (PSP). Saves at {emulatorDir}/memstick/PSP/SAVEDATA/, states at PPSSPP_STATE/. Returns entire PSP/SAVEDATA directory as zip. Restore strips top-level SAVEDATA folder to extract directly into memstick/PSP/SAVEDATA/.
+- `lib/core/save/strategies/cemu_save_strategy.dart` — Cemu save strategy (Wii U). Zips {emulatorDir}/mlc01/usr/save/00050000/ for upload. Restore extracts zip directly into mlc01/usr/save/ skipping .bak entries.
+
+### Core — Emulator
+- `lib/core/emulator/emulator_strategy.dart` — Abstract base class. Fields: name, emulatorId, supportedSlugs, windowsExecutable, linuxExecutable. Methods: launch(Game, romPath), resolveSavePath(Game), getExecutableForPlatform(). Optional launchWithHandle(Game, romPath) method returns Process handle for auto-sync tracking. Default returns null.
+- `lib/core/emulator/emulator_registry_data.dart` — Static data for emulator definitions. Includes RetroArch, Dolphin, Eden, RPCS3, PCSX2, Azahar, Cemu, Xemu, Xenia, DuckStation, Flycast, melonDS, PPSSPP, mGBA, MAME.
+- `lib/core/emulator/strategy_registry.dart` — Registry for emulator strategies. Includes conflict detection via detectConflicts() returning Map<String, List<EmulatorStrategy>> grouped by canonical platform name. setPreference(slug, emulatorId) and loadPreferences() persist user preferences under SharedPreferences key prefix 'emulator_pref_'. loadPreferences() is called on startup in romm_provider.dart to ensure conflict preferences are available before first launch.
+- `lib/core/emulator/emulator_download_service.dart` — Service for downloading emulators. Supports direct URL and GitHub release types. Uses ExtractionService for all extraction.
+- `lib/core/emulator/github_release_service.dart` — Fetches latest release asset URL from GitHub API with required/excluded name filters.
+- `lib/core/emulator/strategies/retroarch_strategy.dart` — RetroArch strategy. Slugs: gba/gbc/gb/nes/snes/n64/nds/psx/ps1/psp/dc/dreamcast/megadrive/genesis/md etc. Throws MissingRetroArchCoreException when a core .dll is missing. Has downloadCore() method to fetch from RetroArch buildbot.
+- `lib/core/emulator/strategies/dolphin_strategy.dart` — Dolphin strategy. Slugs: gc/gamecube/wii/ngc.
+- `lib/core/emulator/strategies/eden_strategy.dart` — Eden strategy. Slugs: switch/nintendo-switch/ns.
+- `lib/core/emulator/strategies/rpcs3_strategy.dart` — RPCS3 strategy. Slugs: ps3/playstation-3/playstation3.
+- `lib/core/emulator/strategies/pcsx2_strategy.dart` — PCSX2 strategy. Slugs: ps2/playstation-2/playstation2.
+- `lib/core/emulator/strategies/azahar_strategy.dart` — Azahar strategy. Slugs: 3ds/n3ds/nintendo-3ds/nintendo3ds/new-nintendo-3ds/new-nintendo-3ds-xl.
+- `lib/core/emulator/strategies/cemu_strategy.dart` — Cemu strategy. Slugs: wiiu/wii-u/nintendo-wii-u/nintendo-wiiu.
+- `lib/core/emulator/strategies/duckstation_strategy.dart` — DuckStation strategy. Slugs: ps1/playstation/psx.
+- `lib/core/emulator/strategies/flycast_strategy.dart` — Flycast strategy. Slugs: dc/dreamcast/naomi/naomi2/atomiswave/cave/hikaru.
+- `lib/core/emulator/strategies/melonds_strategy.dart` — melonDS strategy. Slugs: nds/nintendo-ds/ds.
+- `lib/core/emulator/strategies/ppsspp_strategy.dart` — PPSSPP strategy. Slugs: psp/playstation-portable. Implements launchWithHandle() returning Process with ProcessStartMode.normal for auto-sync on game close.
+- `lib/core/emulator/strategies/mgba_strategy.dart` — mGBA strategy. Slugs: gba/gbc/gb/game-boy-advance/game-boy-color/game-boy.
+- `lib/core/emulator/strategies/mame_strategy.dart` — MAME strategy. Slugs: arcade/mame. Handles self-extracting .exe downloads.
+- `lib/core/emulator/strategies/xemu_strategy.dart` — Xemu strategy. Slugs: xbox.
+- `lib/core/emulator/strategies/xenia_strategy.dart` — Xenia Canary strategy. Slugs: xbox360/xbla.
+- `lib/core/emulator/strategies/windows_strategy.dart` — Windows native game strategy. Auto-detects exe in game folder, validates stored override exists on disk before using. Launches via Process.start. Monitors exit code for 5s — throws if crashed. Persists overrides via SharedPreferences (prefix `win_exe_`).
+
+### Core — Extraction
+- `lib/core/extraction/extraction_service.dart` — Unified extraction service. Handles .zip via archive package, .7z via bundled 7zr.exe, and self-extracting .exe files.
+
+### Core — Downloader
+- `lib/core/downloader/download_service.dart` — HTTP ROM download via Dio. Stream<DownloadProgress> for UI. Uses ExtractionService for all extraction.
+
+### Core — Storage
+- `lib/core/storage/directory_service.dart` — Manages ROMs and emulator directories. Persists paths via SharedPreferences. setEmulatorPathOverride()/getEmulatorPathOverride()/loadEmulatorPathOverrides() allow users to point any emulator to a custom installation directory, persisted under SharedPreferences key prefix 'emu_path_'. resolveSevenZipPath() extracts bundled 7zr.exe from Flutter assets.
+
+### Core — Windows
+- `lib/core/windows/windows_game_service.dart` — Finds main exe in game folder. Launches via Process.start detached.
+- `lib/core/windows/pcgamingwiki_service.dart` — Queries PCGamingWiki API for Windows game save locations.
+
+### Core — Updater
+- `lib/core/updater/updater_service.dart` — Checks GitHub Releases API for new version. Downloads and relaunch.
+
+### Providers
+- `lib/providers/romm_provider.dart` — Riverpod providers for RomM config, connection state, DirectoryService, StrategyRegistry, SaveSyncService. strategyRegistryProvider and saveSyncServiceProvider are FutureProviders that must be awaited with .future to ensure preferences are loaded before use.
+- `lib/providers/library_provider.dart` — Riverpod providers for platforms and games. Search, filtering, and display settings persistence. Background refresh silently updates cache.
+- `lib/providers/download_provider.dart` — Riverpod providers for active downloads (games and emulators) and progress.
+
+### UI — Screens
+- `lib/ui/screens/library_screen.dart` — Main screen. Game grid with search, platform filter, preset display system. Launch flow shows status snackbars at each stage. Catches MissingRetroArchCoreException to offer auto-download of missing core then re-launches. Cache invalidation on pull-to-refresh. Auto-syncs saves when game process exits via launchWithHandle(). Awaits strategyRegistryProvider.future and saveSyncServiceProvider.future on launch to ensure preferences are loaded before strategy lookup.
+- `lib/ui/screens/library_skeleton.dart` — Skeleton loading grid and _SkeletonCard widget. Contains top-level functions buildSkeletonGrid() and calculateCardHeight().
+- `lib/ui/screens/download_screen.dart` — Active downloads list.
+- `lib/ui/screens/settings_screen.dart` — Server config, storage paths, RetroArch sync mode.
+- `lib/ui/screens/settings_display_section.dart` — Extracted display settings section. Contains buildDisplaySection() function with preset chips, column count, card shape, spacing, title and hover toggles.
+- `lib/ui/screens/settings_emulators_section.dart` — Extracted emulators section. Contains buildEmulatorsSection() and buildConflictsSection() functions. Handles emulator download, custom path overrides, and conflict resolution UI.
+
+### UI — Widgets
+- `lib/ui/widgets/game_card.dart` — StatefulWidget (not Consumer). Accepts pre-resolved coverUrl. Uses CachedNetworkImage (memCache: 300x400).
+- `lib/ui/widgets/download_progress_card.dart` — Single download row with progress bar.
+- `lib/ui/widgets/platform_filter_bar.dart` — Platform chip row.
+- `lib/ui/widgets/windows_game_config_dialog.dart` — Dialog for configuring Windows game overrides.
+
+## Key Contracts
+
+### EmulatorStrategy (never change these signatures)
+```dart
+abstract class EmulatorStrategy {
+ String get name;
+ String get emulatorId;
+ List<String> get supportedSlugs;
+ String get windowsExecutable;
+ String get linuxExecutable;
+ String getExecutableForPlatform();
+ Future<void> launch(Game game, String romPath);
+ String resolveSavePath(Game game);
+ bool get supportsSaveSync;
+}
+```
+
+### DownloadProgress
+```dart
+class DownloadProgress {
+ final String id; // e.g., game ID or emulator ID
+ final String gameName; // display name (game.name or emulator name)
+ final double percent;
+ final int bytesReceived;
+ final int totalBytes;
+ final bool isComplete;
+ final String? error;
+}
+```
+
+### RomMConfig
+```dart
+class RomMConfig {
+ final String baseUrl;
+ final String username;
+ final String password;
+}
+```
+
+## Dependencies (pubspec.yaml)
+- `flutter_riverpod` — state management
+- `dio` — HTTP client for API calls and downloads
+- `path_provider` — platform-safe file paths
+- `shared_preferences` — persist RomM config, display settings, overrides
+- `package_info_plus` — read current app version
+- `archive` — zip extraction and creation
+- `cached_network_image` — disk and memory cache for network images
+- `file_picker` — directory and file selection
+- `path` — path manipulation utilities
- `thirdparty/7zr.exe` — bundled 7-Zip console executable for .7z extraction (Flutter asset)
\ No newline at end of file
diff --git a/lib/core/downloader/download_service.dart b/lib/core/downloader/download_service.dart
index e83fab2..ee7cce4 100644
--- a/lib/core/downloader/download_service.dart
+++ b/lib/core/downloader/download_service.dart
@@ -1,8 +1,8 @@
import 'package:dio/dio.dart';
import 'dart:async';
import 'dart:io';
-import 'package:archive/archive_io.dart';
import 'package:freegosy/core/storage/directory_service.dart';
+import 'package:freegosy/core/extraction/extraction_service.dart';
import 'package:freegosy/core/romm/romm_models.dart';
class DownloadProgress {
@@ -30,8 +30,13 @@ class DownloadProgress {
class DownloadService {
final Dio dio;
final DirectoryService directoryService;
+ final ExtractionService extractionService;
- DownloadService({required this.dio, required this.directoryService});
+ DownloadService({
+ required this.dio,
+ required this.directoryService,
+ required this.extractionService,
+ });
Stream<DownloadProgress> download(Game game, String downloadUrl, {Map<String, String>? headers}) async* {
if (await directoryService.isRomDownloaded(game)) {
@@ -67,7 +72,8 @@ class DownloadService {
final isWindowsGame = ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
final isArchive = savePath.toLowerCase().endsWith('.zip') ||
savePath.toLowerCase().endsWith('.7z');
- if (game.isMultiFile || (isWindowsGame && isArchive)) {
+ final shouldExtract = game.isMultiFile || (isWindowsGame && isArchive);
+ if (shouldExtract) {
controller.add(DownloadProgress(
id: game.id,
gameName: game.name,
@@ -114,25 +120,16 @@ class DownloadService {
await Directory(extractDir).create(recursive: true);
- if (zipPath.toLowerCase().endsWith('.7z')) {
- final sevenZipExe = await directoryService.resolveSevenZipPath();
- if (sevenZipExe == null) {
- throw Exception('7zr.exe could not be initialized. Try reinstalling Freegosy.');
- }
- final result = await Process.run(
- sevenZipExe, ['x', zipPath, '-o$extractDir', '-y'],
- runInShell: false,
- );
- if (result.exitCode != 0) {
- throw Exception('7z extraction failed: ${result.stderr}');
+ try {
+ await extractionService.extract(zipPath, extractDir);
+ await File(zipPath).delete();
+ } catch (e) {
+ if (e.toString().contains('Unsupported archive format')) {
+ // File is not an archive - leave it as downloaded
+ await Directory(extractDir).delete();
+ return;
}
- } else {
- final bytes = await File(zipPath).readAsBytes();
- final archive = ZipDecoder().decodeBytes(bytes);
- extractArchiveToDisk(archive, extractDir);
+ rethrow;
}
-
- // Delete the archive after extraction
- await File(zipPath).delete();
}
}
\ No newline at end of file
diff --git a/lib/core/emulator/emulator_download_service.dart b/lib/core/emulator/emulator_download_service.dart
index 979da86..968a204 100644
--- a/lib/core/emulator/emulator_download_service.dart
+++ b/lib/core/emulator/emulator_download_service.dart
@@ -3,18 +3,19 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
-import 'package:archive/archive_io.dart';
import '../downloader/download_service.dart';
import '../storage/directory_service.dart';
+import '../extraction/extraction_service.dart';
import 'emulator_registry_data.dart';
import 'github_release_service.dart';
class EmulatorDownloadService {
final Dio _dio;
final DirectoryService _directoryService;
+ final ExtractionService _extractionService;
late final GithubReleaseService _githubService;
- EmulatorDownloadService(this._dio, this._directoryService) {
+ EmulatorDownloadService(this._dio, this._directoryService, this._extractionService) {
_githubService = GithubReleaseService(_dio);
}
@@ -70,7 +71,7 @@ class EmulatorDownloadService {
yield DownloadProgress(
id: emulatorId,
gameName: emulatorName,
- error: 'No download URL for this platform',
+ error: 'This emulator is not available for your platform',
);
return;
}
@@ -107,7 +108,7 @@ class EmulatorDownloadService {
percent: 1.0,
status: 'Extracting...',
));
- await _extractArchive(tempFilePath, emulatorDir);
+ await _extractionService.extract(tempFilePath, emulatorDir);
controller.add(DownloadProgress(
id: emulatorId,
gameName: emulatorName,
@@ -144,22 +145,4 @@ class EmulatorDownloadService {
);
}
}
-
- Future<void> _extractArchive(String archivePath, String destDir) async {
- if (archivePath.endsWith('.zip')) {
- final bytes = await File(archivePath).readAsBytes();
- final archive = ZipDecoder().decodeBytes(bytes);
- extractArchiveToDisk(archive, destDir);
- } else if (archivePath.endsWith('.7z')) {
- final result = await Process.run(
- '7z', ['x', archivePath, '-o$destDir', '-y'],
- runInShell: true,
- );
- if (result.exitCode != 0) {
- throw Exception('7z extraction failed: ${result.stderr}');
- }
- } else {
- throw Exception('Unsupported archive format: $archivePath');
- }
- }
}
\ No newline at end of file
diff --git a/lib/core/emulator/emulator_registry_data.dart b/lib/core/emulator/emulator_registry_data.dart
index 0aefadf..54ba544 100644
--- a/lib/core/emulator/emulator_registry_data.dart
+++ b/lib/core/emulator/emulator_registry_data.dart
@@ -12,6 +12,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo', 'arcade', 'mame',
'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos'
],
+ 'supported_platforms': ['windows', 'linux'],
},
{
'id': 'dolphin',
@@ -21,6 +22,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'Dolphin.exe',
'linux_executable': 'dolphin-emu',
'platform_slugs': ['gc', 'gamecube', 'wii', 'ngc'],
+ 'supported_platforms': ['windows', 'linux'],
},
{
'id': 'eden',
@@ -30,6 +32,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'eden.exe',
'linux_executable': 'eden',
'platform_slugs': ['switch', 'nintendo-switch', 'ns'],
+ 'supported_platforms': ['windows', 'linux'],
},
{
'id': 'rpcs3',
@@ -41,6 +44,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'rpcs3.exe',
'linux_executable': 'rpcs3',
'platform_slugs': ['ps3', 'playstation-3', 'playstation3'],
+ 'supported_platforms': ['windows', 'linux'],
},
{
'id': 'pcsx2',
@@ -52,6 +56,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'pcsx2-qt.exe',
'linux_executable': 'pcsx2-qt',
'platform_slugs': ['ps2', 'playstation-2', 'playstation2'],
+ 'supported_platforms': ['windows', 'linux'],
},
{
'id': 'azahar',
@@ -63,17 +68,19 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'azahar.exe',
'linux_executable': 'azahar',
'platform_slugs': ['3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'],
+ 'supported_platforms': ['windows', 'linux'],
},
{
'id': 'cemu',
'name': 'Cemu (Wii U)',
'type': 'github',
'github_repo': 'cemu-project/Cemu',
- 'github_asset_required': ['windows'],
- 'github_asset_excluded': ['experimental', 'debug'],
+ 'github_asset_required': ['windows', 'x64', '.zip'],
+ 'github_asset_excluded': ['debug', 'symbols', 'linux', 'macos'],
'windows_executable': 'Cemu.exe',
'linux_executable': 'cemu',
'platform_slugs': ['wiiu', 'wii-u', 'nintendo-wii-u', 'nintendo-wiiu'],
+ 'supported_platforms': ['windows', 'linux'],
},
{
'id': 'xemu',
@@ -85,6 +92,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'xemu.exe',
'linux_executable': 'xemu',
'platform_slugs': ['xbox'],
+ 'supported_platforms': ['windows'],
},
{
'id': 'xenia_canary',
@@ -96,6 +104,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'xenia_canary.exe',
'linux_executable': 'xenia_canary',
'platform_slugs': ['xbox360', 'xbla'],
+ 'supported_platforms': ['windows'],
},
{
'id': 'duckstation',
@@ -107,6 +116,68 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': 'duckstation-qt-x64-ReleaseLTCG.exe',
'linux_executable': 'duckstation-qt',
'platform_slugs': ['ps1', 'playstation', 'psx'],
+ 'supported_platforms': ['windows', 'linux'],
+ },
+ {
+ 'id': 'flycast',
+ 'name': 'Flycast (Dreamcast)',
+ 'type': 'github',
+ 'github_repo': 'flyinghead/flycast',
+ 'github_asset_required': ['win64', '.zip'],
+ 'github_asset_excluded': ['debug', 'symbols', 'linux', 'macos'],
+ 'windows_executable': 'flycast.exe',
+ 'linux_executable': 'flycast',
+ 'platform_slugs': ['dc', 'dreamcast', 'naomi', 'naomi2', 'atomiswave', 'cave', 'hikaru'],
+ 'supported_platforms': ['windows', 'linux'],
+ },
+ {
+ 'id': 'melonds',
+ 'name': 'melonDS',
+ 'type': 'github',
+ 'github_repo': 'melonDS-emu/melonDS',
+ 'github_asset_required': ['win', '.zip'],
+ 'github_asset_excluded': ['source', 'debug'],
+ 'windows_executable': 'melonDS.exe',
+ 'linux_executable': 'melonDS',
+ 'platform_slugs': ['nds', 'nintendo-ds', 'ds'],
+ 'supported_platforms': ['windows', 'linux'],
+ },
+ {
+ 'id': 'ppsspp',
+ 'name': 'PPSSPP (PSP)',
+ 'type': 'github',
+ 'github_repo': 'hrydgard/ppsspp',
+ 'github_asset_required': ['Windows', 'x64', '.zip'],
+ 'github_asset_excluded': ['debug', 'symbols', 'VR'],
+ 'windows_executable': 'PPSSPPWindows64.exe',
+ 'linux_executable': 'PPSSPP',
+ 'supported_platforms': ['windows', 'linux'],
+ 'platform_slugs': ['psp', 'playstation-portable'],
+ },
+ {
+ 'id': 'mgba',
+ 'name': 'mGBA (GBA)',
+ 'type': 'github',
+ 'github_repo': 'mgba-emu/mgba',
+ 'github_asset_required': ['win64', '.7z'],
+ 'github_asset_excluded': ['debug', 'symbols', 'source'],
+ 'windows_executable': 'mGBA.exe',
+ 'linux_executable': 'mgba',
+ 'supported_platforms': ['windows', 'linux'],
+ 'platform_slugs': ['gba', 'gbc', 'gb', 'game-boy-advance', 'game-boy-color', 'game-boy'],
+ },
+ {
+ 'id': 'mame',
+ 'name': 'MAME',
+ 'type': 'github',
+ 'github_repo': 'mamedev/mame',
+ 'github_asset_required': ['x64.exe'],
+ 'github_asset_excluded': ['debug', 'symbols', 'sources', 'tools'],
+ 'extraction_type': 'self_extracting',
+ 'windows_executable': 'mame.exe',
+ 'linux_executable': 'mame',
+ 'supported_platforms': ['windows', 'linux'],
+ 'platform_slugs': ['arcade', 'mame'],
},
{
'id': 'windows_native',
@@ -115,5 +186,6 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'windows_executable': '',
'linux_executable': '',
'platform_slugs': ['windows', 'pc', 'win'],
+ 'supported_platforms': ['windows'],
},
-];
\ No newline at end of file
+];
diff --git a/lib/core/emulator/emulator_strategy.dart b/lib/core/emulator/emulator_strategy.dart
index 3a28d16..b0e70f9 100644
--- a/lib/core/emulator/emulator_strategy.dart
+++ b/lib/core/emulator/emulator_strategy.dart
@@ -1,4 +1,5 @@
-import 'dart:io' as io;
+import 'dart:io';
+import 'dart:io' as io;
import 'package:freegosy/core/romm/romm_models.dart';
abstract class EmulatorStrategy {
@@ -16,5 +17,6 @@ abstract class EmulatorStrategy {
}
Future<void> launch(Game game, String romPath);
+ Future<Process?> launchWithHandle(Game game, String romPath) async => null;
String resolveSavePath(Game game);
}
diff --git a/lib/core/emulator/strategies/azahar_strategy.dart b/lib/core/emulator/strategies/azahar_strategy.dart
index 9e7b673..2322d9f 100644
--- a/lib/core/emulator/strategies/azahar_strategy.dart
+++ b/lib/core/emulator/strategies/azahar_strategy.dart
@@ -38,6 +38,15 @@ class AzaharStrategy extends EmulatorStrategy {
await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) => '';
}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/cemu_strategy.dart b/lib/core/emulator/strategies/cemu_strategy.dart
index 60732ea..326d2e4 100644
--- a/lib/core/emulator/strategies/cemu_strategy.dart
+++ b/lib/core/emulator/strategies/cemu_strategy.dart
@@ -35,6 +35,15 @@ class CemuStrategy extends EmulatorStrategy {
await Process.start(exePath, ['-g', romPath], mode: ProcessStartMode.detached);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, ['-g', romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) => '';
}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/dolphin_strategy.dart b/lib/core/emulator/strategies/dolphin_strategy.dart
index 274c301..a1a8b40 100644
--- a/lib/core/emulator/strategies/dolphin_strategy.dart
+++ b/lib/core/emulator/strategies/dolphin_strategy.dart
@@ -33,6 +33,13 @@ class DolphinStrategy extends EmulatorStrategy {
await Process.run(exePath, ['-e', romPath]);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, ['-e', romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) {
return ''; // Placeholder
diff --git a/lib/core/emulator/strategies/duckstation_strategy.dart b/lib/core/emulator/strategies/duckstation_strategy.dart
index 74f2663..f098b2e 100644
--- a/lib/core/emulator/strategies/duckstation_strategy.dart
+++ b/lib/core/emulator/strategies/duckstation_strategy.dart
@@ -35,6 +35,15 @@ class DuckstationStrategy extends EmulatorStrategy {
await Process.start(exePath, ['-batch', romPath], mode: ProcessStartMode.detached);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, ['-batch', romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) => '';
}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/eden_strategy.dart b/lib/core/emulator/strategies/eden_strategy.dart
index 93c16b1..d72d953 100644
--- a/lib/core/emulator/strategies/eden_strategy.dart
+++ b/lib/core/emulator/strategies/eden_strategy.dart
@@ -33,6 +33,13 @@ class EdenStrategy extends EmulatorStrategy {
await Process.run(exePath, [romPath]);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) {
return ''; // Placeholder
diff --git a/lib/core/emulator/strategies/flycast_strategy.dart b/lib/core/emulator/strategies/flycast_strategy.dart
new file mode 100644
index 0000000..6606830
--- /dev/null
+++ b/lib/core/emulator/strategies/flycast_strategy.dart
@@ -0,0 +1,49 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class FlycastStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ FlycastStrategy(this._directoryService);
+
+ @override
+ String get name => 'Flycast';
+
+ @override
+ String get emulatorId => 'flycast';
+
+ @override
+ List<String> get supportedSlugs => ['dc', 'dreamcast', 'naomi', 'atomiswave'];
+
+ @override
+ String get windowsExecutable => 'flycast.exe';
+
+ @override
+ String get linuxExecutable => 'flycast';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
diff --git a/lib/core/emulator/strategies/mame_strategy.dart b/lib/core/emulator/strategies/mame_strategy.dart
new file mode 100644
index 0000000..9847c9c
--- /dev/null
+++ b/lib/core/emulator/strategies/mame_strategy.dart
@@ -0,0 +1,69 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class MAMEStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ MAMEStrategy(this._directoryService);
+
+ @override
+ String get name => 'MAME';
+
+ @override
+ String get emulatorId => 'mame';
+
+ @override
+ List<String> get supportedSlugs => ['arcade', 'mame'];
+
+ @override
+ String get windowsExecutable => 'mame.exe';
+
+ @override
+ String get linuxExecutable => 'mame';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final emulatorDir = await _directoryService.getEmulatorDirectory(emulatorId);
+ final mameExePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, windowsExecutable,
+ );
+
+ if (mameExePath == null) {
+ // Check for self-extracting exe in the emulator directory
+ final dir = Directory(emulatorDir);
+ if (await dir.exists()) {
+ final files = await dir.list().toList();
+ final setupExe = files.firstWhere(
+ (f) => f is File && f.path.toLowerCase().endsWith('.exe') && !f.path.toLowerCase().endsWith('mame.exe'),
+ orElse: () => File(''),
+ );
+
+ if (setupExe.path.isNotEmpty) {
+ // It's a self-extracting exe, run it to extract
+ // We run it with current directory set to emulatorDir so it extracts there
+ await Process.run(setupExe.path, [], workingDirectory: emulatorDir);
+
+ // Try finding mame.exe again
+ final retryPath = await _directoryService.findEmulatorExecutable(
+ emulatorId, windowsExecutable,
+ );
+ if (retryPath != null) {
+ await Process.start(retryPath, [romPath], mode: ProcessStartMode.detached);
+ return;
+ }
+ }
+ }
+ throw Exception('$name not found. Please download it first.');
+ }
+
+ await Process.start(mameExePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
diff --git a/lib/core/emulator/strategies/melonds_strategy.dart b/lib/core/emulator/strategies/melonds_strategy.dart
new file mode 100644
index 0000000..345745f
--- /dev/null
+++ b/lib/core/emulator/strategies/melonds_strategy.dart
@@ -0,0 +1,52 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class MelonDSStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ MelonDSStrategy(this._directoryService);
+
+ @override
+ String get name => 'melonDS';
+
+ @override
+ String get emulatorId => 'melonds';
+
+ @override
+ List<String> get supportedSlugs => ['nds', 'nintendo-ds', 'ds'];
+
+ @override
+ String get windowsExecutable => 'melonDS.exe';
+
+ @override
+ String get linuxExecutable => 'melonDS';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ final workingDir = File(exePath).parent.path;
+ await Process.start(exePath, [romPath], workingDirectory: workingDir, mode: ProcessStartMode.detached);
+ }
+
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(emulatorId, getExecutableForPlatform());
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ final workingDir = File(exePath).parent.path;
+ final process = await Process.start(exePath, [romPath], workingDirectory: workingDir, mode: ProcessStartMode.normal);
+ process.stdout.drain();
+ process.stderr.drain();
+ return process;
+ }
+
+ @override
+ String resolveSavePath(Game game) {
+ return ''; // Placeholder
+ }
+}
diff --git a/lib/core/emulator/strategies/mgba_strategy.dart b/lib/core/emulator/strategies/mgba_strategy.dart
new file mode 100644
index 0000000..8aee232
--- /dev/null
+++ b/lib/core/emulator/strategies/mgba_strategy.dart
@@ -0,0 +1,49 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class MGBAStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ MGBAStrategy(this._directoryService);
+
+ @override
+ String get name => 'mGBA';
+
+ @override
+ String get emulatorId => 'mgba';
+
+ @override
+ List<String> get supportedSlugs => ['gba', 'gbc', 'gb', 'game-boy-advance', 'game-boy-color', 'game-boy'];
+
+ @override
+ String get windowsExecutable => 'mGBA.exe';
+
+ @override
+ String get linuxExecutable => 'mgba';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
diff --git a/lib/core/emulator/strategies/pcsx2_strategy.dart b/lib/core/emulator/strategies/pcsx2_strategy.dart
index 8612e37..8fd512e 100644
--- a/lib/core/emulator/strategies/pcsx2_strategy.dart
+++ b/lib/core/emulator/strategies/pcsx2_strategy.dart
@@ -37,6 +37,17 @@ class Pcsx2Strategy extends EmulatorStrategy {
await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.detached);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ final normalizedRom = romPath.replaceAll('/', '\\');
+ final normalizedExe = exePath.replaceAll('/', '\\');
+ return await Process.start(normalizedExe, [normalizedRom], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) => '';
}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/ppsspp_strategy.dart b/lib/core/emulator/strategies/ppsspp_strategy.dart
new file mode 100644
index 0000000..394f752
--- /dev/null
+++ b/lib/core/emulator/strategies/ppsspp_strategy.dart
@@ -0,0 +1,49 @@
+import 'dart:io';
+import 'package:freegosy/core/emulator/emulator_strategy.dart';
+import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/storage/directory_service.dart';
+
+class PPSSPPStrategy extends EmulatorStrategy {
+ final DirectoryService _directoryService;
+
+ PPSSPPStrategy(this._directoryService);
+
+ @override
+ String get name => 'PPSSPP';
+
+ @override
+ String get emulatorId => 'ppsspp';
+
+ @override
+ List<String> get supportedSlugs => ['psp', 'playstation-portable'];
+
+ @override
+ String get windowsExecutable => 'PPSSPPWindows64.exe';
+
+ @override
+ String get linuxExecutable => 'PPSSPP';
+
+ @override
+ bool get supportsSaveSync => false;
+
+ @override
+ Future<void> launch(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
+ }
+
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
+ }
+
+ @override
+ String resolveSavePath(Game game) => '';
+}
diff --git a/lib/core/emulator/strategies/retroarch_strategy.dart b/lib/core/emulator/strategies/retroarch_strategy.dart
index 9950744..dbff3b6 100644
--- a/lib/core/emulator/strategies/retroarch_strategy.dart
+++ b/lib/core/emulator/strategies/retroarch_strategy.dart
@@ -1,8 +1,27 @@
-import 'dart:io';
+import 'dart:io';
+import 'package:dio/dio.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'package:archive/archive_io.dart';
import 'package:freegosy/core/emulator/emulator_strategy.dart';
import 'package:freegosy/core/romm/romm_models.dart';
import 'package:freegosy/core/storage/directory_service.dart';
+class MissingRetroArchCoreException implements Exception {
+ final String coreName;
+ final String corePath;
+ final String exePath;
+
+ MissingRetroArchCoreException({
+ required this.coreName,
+ required this.corePath,
+ required this.exePath,
+ });
+
+ @override
+ String toString() => 'Missing RetroArch Core: $coreName at $corePath';
+}
+
class RetroArchStrategy extends EmulatorStrategy {
final DirectoryService _directoryService;
@@ -16,13 +35,13 @@ class RetroArchStrategy extends EmulatorStrategy {
@override
List<String> get supportedSlugs => [
- 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds',
+ 'gba', 'gbc', 'gb', 'nes', 'snes', 'n64', 'nds',
'psx', 'ps1', 'playstation',
'psp',
- 'segacd', 'saturn',
+ 'segacd', 'saturn',
'dc', 'dreamcast',
'megadrive', 'genesis', 'md',
- 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo',
+ 'gamegear', 'atari2600', 'atari7800', 'lynx', 'neogeo',
'arcade', 'mame', 'pcengine', 'wonderswan', 'virtualboy', 'msx', 'dos'
];
@@ -77,7 +96,11 @@ class RetroArchStrategy extends EmulatorStrategy {
final corePath = '$exeDir\\cores\\$coreName';
if (!await File(corePath).exists()) {
- throw Exception('Core $coreName not found at $corePath. Please download it in RetroArch first.');
+ throw MissingRetroArchCoreException(
+ coreName: coreName,
+ corePath: corePath,
+ exePath: normalizedExe,
+ );
}
await Process.start(
@@ -87,6 +110,66 @@ class RetroArchStrategy extends EmulatorStrategy {
);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform());
+ if (exePath == null) {
+ throw Exception('$name not found. Please download it first.');
+ }
+
+ final normalizedExe = exePath.replaceAll('/', r'\');
+ final normalizedRom = romPath.replaceAll('/', r'\');
+ final coreName = _getCoreForSlug(game.platformSlug);
+
+ if (coreName == null) {
+ return await Process.start(
+ normalizedExe,
+ [normalizedRom],
+ mode: ProcessStartMode.normal,
+ );
+ }
+
+ final exeDir = File(normalizedExe).parent.path;
+ final corePath = '$exeDir\\cores\\$coreName';
+
+ if (!await File(corePath).exists()) {
+ throw MissingRetroArchCoreException(
+ coreName: coreName,
+ corePath: corePath,
+ exePath: normalizedExe,
+ );
+ }
+
+ return await Process.start(
+ normalizedExe,
+ ['-L', corePath, normalizedRom],
+ mode: ProcessStartMode.normal,
+ );
+ }
+
+ Future<void> downloadCore(String coreName, String coresDir, Dio dio) async {
+ final url = 'https://buildbot.libretro.com/nightly/windows/x86_64/latest/$coreName.zip';
+ final tempDir = await getTemporaryDirectory();
+ final zipPath = p.join(tempDir.path, '$coreName.zip');
+
+ try {
+ await dio.download(url, zipPath);
+ final bytes = await File(zipPath).readAsBytes();
+ final archive = ZipDecoder().decodeBytes(bytes);
+ for (final entry in archive) {
+ if (entry.isFile && entry.name.endsWith('.dll')) {
+ final outFile = File('$coresDir\\${entry.name}');
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(entry.content as List<int>);
+ }
+ }
+ } finally {
+ final f = File(zipPath);
+ if (await f.exists()) await f.delete();
+ }
+ }
+
@override
String resolveSavePath(Game game) {
return '';
diff --git a/lib/core/emulator/strategies/rpcs3_strategy.dart b/lib/core/emulator/strategies/rpcs3_strategy.dart
index 9828c67..1df09ca 100644
--- a/lib/core/emulator/strategies/rpcs3_strategy.dart
+++ b/lib/core/emulator/strategies/rpcs3_strategy.dart
@@ -35,6 +35,15 @@ class Rpcs3Strategy extends EmulatorStrategy {
await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) => '';
}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/xemu_strategy.dart b/lib/core/emulator/strategies/xemu_strategy.dart
index 8d433e8..133272d 100644
--- a/lib/core/emulator/strategies/xemu_strategy.dart
+++ b/lib/core/emulator/strategies/xemu_strategy.dart
@@ -35,6 +35,15 @@ class XemuStrategy extends EmulatorStrategy {
await Process.start(exePath, ['-dvd_path', romPath], mode: ProcessStartMode.detached);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, ['-dvd_path', romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) => '';
}
\ No newline at end of file
diff --git a/lib/core/emulator/strategies/xenia_strategy.dart b/lib/core/emulator/strategies/xenia_strategy.dart
index fac9475..7183a07 100644
--- a/lib/core/emulator/strategies/xenia_strategy.dart
+++ b/lib/core/emulator/strategies/xenia_strategy.dart
@@ -35,6 +35,15 @@ class XeniaStrategy extends EmulatorStrategy {
await Process.start(exePath, [romPath], mode: ProcessStartMode.detached);
}
+ @override
+ Future<Process?> launchWithHandle(Game game, String romPath) async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ emulatorId, getExecutableForPlatform(),
+ );
+ if (exePath == null) throw Exception('$name not found. Please download it first.');
+ return await Process.start(exePath, [romPath], mode: ProcessStartMode.normal);
+ }
+
@override
String resolveSavePath(Game game) => '';
}
\ No newline at end of file
diff --git a/lib/core/emulator/strategy_registry.dart b/lib/core/emulator/strategy_registry.dart
index 8c46f8f..b60d6cf 100644
--- a/lib/core/emulator/strategy_registry.dart
+++ b/lib/core/emulator/strategy_registry.dart
@@ -1,4 +1,6 @@
+import 'dart:io';
import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:freegosy/core/emulator/emulator_strategy.dart';
import 'package:freegosy/core/emulator/strategies/retroarch_strategy.dart';
import 'package:freegosy/core/emulator/strategies/dolphin_strategy.dart';
@@ -8,6 +10,11 @@ import 'package:freegosy/core/emulator/strategies/pcsx2_strategy.dart';
import 'package:freegosy/core/emulator/strategies/azahar_strategy.dart';
import 'package:freegosy/core/emulator/strategies/cemu_strategy.dart';
import 'package:freegosy/core/emulator/strategies/duckstation_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/flycast_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/melonds_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/mgba_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/mame_strategy.dart';
+import 'package:freegosy/core/emulator/strategies/ppsspp_strategy.dart';
import 'package:freegosy/core/emulator/strategies/xemu_strategy.dart';
import 'package:freegosy/core/emulator/strategies/xenia_strategy.dart';
import 'package:freegosy/core/emulator/emulator_registry_data.dart';
@@ -17,9 +24,10 @@ import 'package:freegosy/core/emulator/strategies/windows_strategy.dart';
class StrategyRegistry {
final DirectoryService _directoryService;
late final List<EmulatorStrategy> _strategies;
+ final Map<String, String> _slugPreferences = {};
StrategyRegistry(this._directoryService) {
- _strategies = [
+ final List<EmulatorStrategy> allPossibleStrategies = [
RetroArchStrategy(_directoryService),
DolphinStrategy(_directoryService),
EdenStrategy(_directoryService),
@@ -28,14 +36,123 @@ class StrategyRegistry {
AzaharStrategy(_directoryService),
CemuStrategy(_directoryService),
DuckstationStrategy(_directoryService),
+ FlycastStrategy(_directoryService),
+ MelonDSStrategy(_directoryService),
+ PPSSPPStrategy(_directoryService),
+ MGBAStrategy(_directoryService),
+ MAMEStrategy(_directoryService),
XemuStrategy(_directoryService),
XeniaStrategy(_directoryService),
WindowsStrategy(_directoryService),
];
+
+ _strategies = allPossibleStrategies.where((strategy) {
+ final definition = getDefinition(strategy.emulatorId);
+ if (definition == null) return true; // Default to including if no definition found
+ final supported = List<String>.from(definition['supported_platforms'] ?? []);
+ if (Platform.isWindows && supported.contains('windows')) return true;
+ if (Platform.isLinux && supported.contains('linux')) return true;
+ return false;
+ }).toList();
+ }
+
+ Map<String, List<EmulatorStrategy>> detectConflicts() {
+ final Map<String, List<EmulatorStrategy>> slugToStrategies = {};
+ for (final strategy in _strategies) {
+ for (final slug in strategy.supportedSlugs) {
+ slugToStrategies.putIfAbsent(slug, () => []).add(strategy);
+ }
+ }
+
+ // Identify slugs with conflicts
+ final Map<String, List<EmulatorStrategy>> allConflicts = {};
+ slugToStrategies.forEach((slug, list) {
+ if (list.length > 1) {
+ allConflicts[slug] = list;
+ }
+ });
+
+ if (allConflicts.isEmpty) return {};
+
+ // Group slugs that have the exact same set of strategies
+ final Map<String, List<String>> groups = {}; // key: stringified sorted emulator IDs, value: list of slugs
+ allConflicts.forEach((slug, strategies) {
+ final ids = strategies.map((s) => s.emulatorId).toList()..sort();
+ final key = ids.join('|');
+ groups.putIfAbsent(key, () => []).add(slug);
+ });
+
+ final Map<String, List<EmulatorStrategy>> canonicalConflicts = {};
+ groups.forEach((key, slugs) {
+ // Pick canonical name: longest slug
+ final canonical = slugs.reduce((a, b) => a.length > b.length ? a : b);
+ // Strategies are the same for all slugs in this group
+ canonicalConflicts[canonical] = allConflicts[slugs.first]!;
+ });
+
+ return canonicalConflicts;
+ }
+
+ String? getPreferredEmulatorId(String slug) => _slugPreferences[slug];
+
+ Future<void> loadPreferences() async {
+ final prefs = await SharedPreferences.getInstance();
+ for (final key in prefs.getKeys()) {
+ if (key.startsWith('emulator_pref_')) {
+ final slug = key.replaceFirst('emulator_pref_', '');
+ final emulatorId = prefs.getString(key);
+ if (emulatorId != null) {
+ _slugPreferences[slug] = emulatorId;
+ }
+ }
+ }
+ }
+
+ Future<void> setPreference(String canonicalSlug, String emulatorId) async {
+ final prefs = await SharedPreferences.getInstance();
+
+ // Find all slugs that belong to the same group as this canonicalSlug
+ final slugToStrategies = <String, List<String>>{};
+ for (final strategy in _strategies) {
+ for (final slug in strategy.supportedSlugs) {
+ slugToStrategies.putIfAbsent(slug, () => []).add(strategy.emulatorId);
+ }
+ }
+
+ final targetStrategies = slugToStrategies[canonicalSlug];
+ if (targetStrategies == null) {
+ // Fallback: just set for this slug
+ await prefs.setString('emulator_pref_$canonicalSlug', emulatorId);
+ _slugPreferences[canonicalSlug] = emulatorId;
+ return;
+ }
+
+ targetStrategies.sort();
+ final targetKey = targetStrategies.join('|');
+
+ // Apply preference to all slugs with the same strategy set
+ for (final entry in slugToStrategies.entries) {
+ final ids = entry.value..sort();
+ if (ids.join('|') == targetKey) {
+ final slug = entry.key;
+ await prefs.setString('emulator_pref_$slug', emulatorId);
+ _slugPreferences[slug] = emulatorId;
+ }
+ }
}
EmulatorStrategy? getStrategyForSlug(String platformSlug) {
if (kIsWeb) return null;
+
+ final preferredId = _slugPreferences[platformSlug];
+ if (preferredId != null) {
+ for (final strategy in _strategies) {
+ if (strategy.emulatorId == preferredId) {
+ return strategy;
+ }
+ }
+ }
+
for (final strategy in _strategies) {
if (strategy.supportedSlugs.contains(platformSlug)) {
return strategy;
diff --git a/lib/core/extraction/extraction_service.dart b/lib/core/extraction/extraction_service.dart
new file mode 100644
index 0000000..5880ed9
--- /dev/null
+++ b/lib/core/extraction/extraction_service.dart
@@ -0,0 +1,70 @@
+import 'dart:io';
+import 'package:archive/archive_io.dart';
+import 'package:flutter/foundation.dart';
+import '../storage/directory_service.dart';
+
+Future<void> _extractZipIsolate(List<dynamic> args) async {
+ final bytes = args[0] as Uint8List;
+ final destDir = args[1] as String;
+ final archive = ZipDecoder().decodeBytes(bytes);
+ extractArchiveToDisk(archive, destDir);
+}
+
+class ExtractionService {
+ final DirectoryService directoryService;
+
+ ExtractionService(this.directoryService);
+
+ Future<void> extract(String archivePath, String destDir) async {
+ final pathLower = archivePath.toLowerCase();
+
+ if (pathLower.endsWith('.zip')) {
+ final fileBytes = await File(archivePath).readAsBytes();
+ await compute(_extractZipIsolate, [fileBytes, destDir]);
+ } else if (pathLower.endsWith('.7z')) {
+ final sevenZipExe = await directoryService.resolveSevenZipPath();
+ if (sevenZipExe == null) {
+ throw Exception('7zr.exe could not be initialized. Try reinstalling Freegosy.');
+ }
+ final result = await Process.run(
+ sevenZipExe,
+ ['x', archivePath, '-o$destDir', '-y'],
+ runInShell: false,
+ );
+ if (result.exitCode != 0) {
+ throw Exception('7z extraction failed: ${result.stderr}');
+ }
+ } else if (pathLower.endsWith('.exe')) {
+ // Self-extracting archive
+ var result = await Process.run(
+ archivePath,
+ ['-o$destDir', '-y'],
+ runInShell: false,
+ );
+ if (result.exitCode != 0) {
+ // Try as a plain self-extractor with no arguments
+ result = await Process.run(
+ archivePath,
+ [],
+ workingDirectory: destDir,
+ );
+ }
+ } else {
+ // Try ZIP magic bytes (PK = 0x50 0x4B)
+ bool isZip = false;
+ try {
+ final raf = await File(archivePath).open();
+ final header = await raf.read(4);
+ await raf.close();
+ isZip = header.length >= 2 && header[0] == 0x50 && header[1] == 0x4B;
+ } catch (_) {}
+
+ if (isZip) {
+ final fileBytes = await File(archivePath).readAsBytes();
+ await compute(_extractZipIsolate, [fileBytes, destDir]);
+ } else {
+ throw Exception('Unsupported archive format: $archivePath');
+ }
+ }
+ }
+}
diff --git a/lib/core/save/save_strategy.dart b/lib/core/save/save_strategy.dart
index e854ffd..52e9077 100644
--- a/lib/core/save/save_strategy.dart
+++ b/lib/core/save/save_strategy.dart
@@ -32,7 +32,7 @@ abstract class SaveStrategy {
if (await bak.exists()) await bak.rename('$path.bak1');
await file.copy('$path.bak');
} catch (e) {
- // Error handled silently
+ // silent
}
}
diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart
index ddda8da..4b3139a 100644
--- a/lib/core/save/save_sync_service.dart
+++ b/lib/core/save/save_sync_service.dart
@@ -11,10 +11,17 @@ import 'strategies/windows_save_strategy.dart';
import 'strategies/pcsx2_save_strategy.dart';
import 'strategies/rpcs3_save_strategy.dart';
import 'strategies/xenia_save_strategy.dart';
+import 'strategies/duckstation_save_strategy.dart';
+import 'strategies/melonds_save_strategy.dart';
+import 'strategies/mgba_save_strategy.dart';
+import 'strategies/ppsspp_save_strategy.dart';
+import 'strategies/cemu_save_strategy.dart';
+import '../emulator/strategy_registry.dart';
class SaveSyncService {
final RommService _rommService;
final DirectoryService _directoryService;
+ final StrategyRegistry _strategyRegistry;
late final RetroArchSaveStrategy _retroarch;
late final DolphinSaveStrategy _dolphin;
@@ -23,35 +30,78 @@ class SaveSyncService {
late final Pcsx2SaveStrategy _pcsx2;
late final Rpcs3SaveStrategy _rpcs3;
late final XeniaSaveStrategy _xenia;
+ late final DuckstationSaveStrategy _duckstation;
+ late final MelonDsSaveStrategy _melonds;
+ late final MgbaSaveStrategy _mgba;
+ late final PpssppSaveStrategy _ppsspp;
+ late final CemuSaveStrategy _cemu;
- SaveSyncService(this._rommService, this._directoryService) {
+ SaveSyncService(this._rommService, this._directoryService, this._strategyRegistry) {
_retroarch = RetroArchSaveStrategy(_directoryService);
_dolphin = DolphinSaveStrategy(_directoryService);
_eden = EdenSaveStrategy();
_windows = WindowsSaveStrategy();
_pcsx2 = Pcsx2SaveStrategy(_directoryService);
_rpcs3 = Rpcs3SaveStrategy(_directoryService);
+ _xenia = XeniaSaveStrategy(_directoryService);
+ _duckstation = DuckstationSaveStrategy(_directoryService);
+ _melonds = MelonDsSaveStrategy();
+ _mgba = MgbaSaveStrategy();
+ _ppsspp = PpssppSaveStrategy(_directoryService);
+ _cemu = CemuSaveStrategy(_directoryService);
}
/// Returns the appropriate save strategy for [platformSlug], or null if unsupported.
SaveStrategy? getStrategyForSlug(String? platformSlug) {
+ // print('[SaveSync] getStrategyForSlug called with: $platformSlug');
+ if (platformSlug != null) {
+ final preferredId = _strategyRegistry.getPreferredEmulatorId(platformSlug);
+ // print('[SaveSync] preferredId for $platformSlug: $preferredId');
+ if (preferredId != null) {
+ final id = preferredId.toLowerCase();
+ if (id == 'melonds') return _melonds;
+ if (id == 'mgba') return _mgba;
+ if (id == 'duckstation') return _duckstation;
+ if (id == 'retroarch') return _retroarch;
+ if (id == 'ppsspp') return _ppsspp;
+ if (id == 'cemu') return _cemu;
+ if (id == 'pcsx2') return _pcsx2;
+ if (id == 'rpcs3') return _rpcs3;
+ if (id == 'dolphin') return _dolphin;
+ if (id == 'xenia' || id == 'xenia_canary') return _xenia;
+ if (id == 'eden') return _eden;
+ if (id == 'windows') return _windows;
+ }
+ }
+
switch (platformSlug?.toLowerCase()) {
case 'gba':
case 'gbc':
case 'gb':
+ case 'game-boy-advance':
+ case 'game-boy-color':
+ case 'game-boy':
+ return _mgba;
case 'snes':
case 'nes':
case 'n64':
+ case 'megadrive':
+ case 'genesis':
+ case 'md':
+ return _retroarch;
case 'nds':
+ case 'nintendo-ds':
+ case 'ds':
+ return _melonds;
case 'psx':
case 'ps1':
case 'playstation':
+ return _duckstation;
case 'psp':
+ case 'playstation-portable':
+ return _ppsspp;
case 'dc':
case 'dreamcast':
- case 'megadrive':
- case 'genesis':
- case 'md':
return _retroarch;
case 'gc':
case 'ngc':
@@ -66,7 +116,6 @@ class SaveSyncService {
case 'pc':
case 'win':
return _windows;
- // These emulators don't have save sync yet
case 'ps2':
case 'playstation-2':
case 'playstation2':
@@ -78,16 +127,17 @@ class SaveSyncService {
case 'xbox360':
case 'xbla':
return _xenia;
+ case 'wiiu':
+ case 'wii-u':
+ case 'nintendo-wii-u':
+ case 'nintendo-wiiu':
+ return _cemu;
case '3ds':
case 'n3ds':
case 'nintendo-3ds':
case 'nintendo3ds':
case 'new-nintendo-3ds':
case 'new-nintendo-3ds-xl':
- case 'wiiu':
- case 'wii-u':
- case 'nintendo-wii-u':
- case 'nintendo-wiiu':
case 'xbox':
return null;
default:
@@ -156,6 +206,7 @@ class SaveSyncService {
if (save == null) {
return false;
}
+ // print('[Pull] getLatestSave result: $save');
final downloadUrl = save['download_path'] as String? ?? save['url'] as String?;
if (downloadUrl == null) {
@@ -166,13 +217,16 @@ class SaveSyncService {
if (bytes == null) {
return false;
}
+ // print('[Pull] downloaded bytes: ${bytes?.length}');
final filename = save['file_name'] as String? ??
downloadUrl.split('/').last;
final ok = await strategy.restoreSave(game, romPath, bytes, filename);
+ // print('[Pull] restoreSave result: $ok');
return ok;
} catch (e) {
+ // print('[Pull] error: $e'); // Removed print statement
rethrow;
}
}
diff --git a/lib/core/save/strategies/cemu_save_strategy.dart b/lib/core/save/strategies/cemu_save_strategy.dart
new file mode 100644
index 0000000..814b2d0
--- /dev/null
+++ b/lib/core/save/strategies/cemu_save_strategy.dart
@@ -0,0 +1,69 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:archive/archive_io.dart';
+import '../../romm/romm_models.dart';
+import '../../storage/directory_service.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for Cemu (Wii U).
+class CemuSaveStrategy extends SaveStrategy {
+ final DirectoryService _directoryService;
+
+ CemuSaveStrategy(this._directoryService);
+
+ @override
+ String get strategyId => 'cemu';
+
+ Future<String?> _getEmulatorDir() async {
+ final dir = await _directoryService.getEmulatorDirectory('cemu');
+ if (!await Directory(dir).exists()) return null;
+ return dir.replaceAll('/', '\\');
+ }
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ final emuDir = await _getEmulatorDir();
+ if (emuDir == null) return null;
+ return '$emuDir\\mlc01\\usr\\save';
+ }
+
+ @override
+ Future<List<File>> getSaveFiles(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
+ final emuDir = await _getEmulatorDir();
+ if (emuDir == null) return [];
+ final saveRoot = Directory('$emuDir\\mlc01\\usr\\save\\00050000');
+ if (!await saveRoot.exists()) return [];
+ return [File(saveRoot.path)]; // Return the directory as a single item
+ }
+
+ @override
+ Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async {
+ try {
+ final emuDir = await _getEmulatorDir();
+ if (emuDir == null) return false;
+ final saveRoot = '$emuDir\\mlc01\\usr\\save';
+ await Directory(saveRoot).create(recursive: true);
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final entry in archive) {
+ if (entry.name.contains('.bak')) continue;
+ final entryPath = entry.name.replaceAll('/', '\\');
+ if (entryPath.isEmpty || entryPath.endsWith('\\')) continue;
+ final targetPath = '$saveRoot\\$entryPath'; if (entry.isFile) {
+ await backupSave(targetPath);
+ final outFile = File(targetPath);
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(entry.content as List<int>);
+ } else {
+ await Directory(targetPath).create(recursive: true);
+ }
+ }
+ return true;
+ }
+ return false;
+ } catch (e) {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/core/save/strategies/duckstation_save_strategy.dart b/lib/core/save/strategies/duckstation_save_strategy.dart
new file mode 100644
index 0000000..c4ee211
--- /dev/null
+++ b/lib/core/save/strategies/duckstation_save_strategy.dart
@@ -0,0 +1,122 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:archive/archive_io.dart';
+import '../../romm/romm_models.dart';
+import '../../storage/directory_service.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for DuckStation (PlayStation 1).
+class DuckstationSaveStrategy extends SaveStrategy {
+ final DirectoryService _directoryService;
+
+ DuckstationSaveStrategy(this._directoryService);
+
+ @override
+ String get strategyId => 'duckstation';
+
+ Future<String?> _getBaseDir() async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ 'duckstation', 'duckstation-qt-x64-ReleaseLTCG.exe');
+ if (exePath == null) return null;
+ final emulatorDir = File(exePath).parent.path.replaceAll('/', '\\');
+
+ if (await File('$emulatorDir\\portable.txt').exists()) {
+ return emulatorDir;
+ }
+
+ try {
+ final result = await Process.run('cmd', ['/c', 'echo %LOCALAPPDATA%'], runInShell: false);
+ final localAppData = result.stdout.toString().trim();
+ if (localAppData.isNotEmpty && !localAppData.contains('%')) {
+ return '$localAppData\\DuckStation';
+ }
+ } catch (e) {
+ // ignore
+ }
+ return emulatorDir;
+ }
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ final baseDir = await _getBaseDir();
+ if (baseDir == null) return null;
+ return '$baseDir\\memcards';
+ }
+
+ @override
+ Future<List<File>> getSaveFiles(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
+ final baseDir = await _getBaseDir();
+ if (baseDir == null) return [];
+
+ final result = <File>[];
+ final stem = getRomStem(game);
+
+ final memcardsDir = Directory('$baseDir\\memcards');
+ if (await memcardsDir.exists()) {
+ await for (final entity in memcardsDir.list()) {
+ if (entity is File &&
+ entity.path.toLowerCase().contains(stem.toLowerCase()) &&
+ entity.path.toLowerCase().endsWith('.mcd')) {
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
+ }
+ result.add(entity);
+ }
+ }
+ }
+
+ final statesDir = Directory('$baseDir\\savestates');
+ if (await statesDir.exists()) {
+ await for (final entity in statesDir.list()) {
+ if (entity is File && entity.path.toLowerCase().contains(stem.toLowerCase())) {
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
+ }
+ result.add(entity);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ @override
+ Future<bool> restoreSave(
+ Game game, String destPath, Uint8List data, String filename) async {
+ try {
+ final baseDir = await _getBaseDir();
+ if (baseDir == null) return false;
+
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final entry in archive) {
+ if (!entry.isFile) continue;
+ final entryLower = entry.name.toLowerCase();
+ final targetDir = entryLower.endsWith('.mcd')
+ ? '$baseDir\\memcards'
+ : '$baseDir\\savestates';
+
+ final targetPath = '$targetDir\\${entry.name.split('\\').last.split('/').last}';
+ await backupSave(targetPath);
+ final outFile = File(targetPath);
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(entry.content as List<int>);
+ }
+ return true;
+ }
+
+ final isState = !filename.toLowerCase().endsWith('.mcd');
+ final targetDir = isState ? '$baseDir\\savestates' : '$baseDir\\memcards';
+ final targetPath = '$targetDir\\$filename';
+ await Directory(targetDir).create(recursive: true);
+ await backupSave(targetPath);
+ await File(targetPath).writeAsBytes(data);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+}
diff --git a/lib/core/save/strategies/melonds_save_strategy.dart b/lib/core/save/strategies/melonds_save_strategy.dart
new file mode 100644
index 0000000..641e397
--- /dev/null
+++ b/lib/core/save/strategies/melonds_save_strategy.dart
@@ -0,0 +1,68 @@
+import 'dart:io';
+import 'dart:typed_data';
+import '../../romm/romm_models.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for melonDS (Nintendo DS).
+class MelonDsSaveStrategy extends SaveStrategy {
+ @override
+ String get strategyId => 'melonds';
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ return File(romPath).parent.path;
+ }
+
+ @override
+ Future<List<File>> getSaveFiles(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
+ final romFile = File(romPath);
+ final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), '');
+ final saveFile = File('${File(romPath).parent.path}/$romStem.sav');
+
+ if (await saveFile.exists()) {
+ if (sessionStart != null) {
+ final stat = await saveFile.stat();
+ if (stat.modified.isBefore(sessionStart)) return [];
+ }
+ return [saveFile];
+ } else {
+ // Fallback to getRomStem(game)
+ final fallbackStem = getRomStem(game);
+ final fallbackSaveFile = File('${File(romPath).parent.path}/$fallbackStem.sav');
+ if (await fallbackSaveFile.exists()) {
+ if (sessionStart != null) {
+ final stat = await fallbackSaveFile.stat();
+ if (stat.modified.isBefore(sessionStart)) return [];
+ }
+ return [fallbackSaveFile];
+ }
+ }
+ return [];
+ }
+
+ @override
+ Future<bool> restoreSave(
+ Game game, String destPath, Uint8List data, String filename) async {
+ try {
+ final romFile = File(destPath);
+ final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), '');
+ final targetPath = '${File(destPath).parent.path}/$romStem.sav';
+
+ if (await File(targetPath).exists()) {
+ await backupSave(targetPath);
+ await File(targetPath).writeAsBytes(data);
+ return true;
+ } else {
+ // Fallback to getRomStem(game)
+ final fallbackStem = getRomStem(game);
+ final fallbackTargetPath = '${File(destPath).parent.path}/$fallbackStem.sav';
+ await backupSave(fallbackTargetPath);
+ await File(fallbackTargetPath).writeAsBytes(data);
+ return true;
+ }
+ } catch (e) {
+ return false;
+ }
+ }
+}
diff --git a/lib/core/save/strategies/mgba_save_strategy.dart b/lib/core/save/strategies/mgba_save_strategy.dart
new file mode 100644
index 0000000..6cdde1b
--- /dev/null
+++ b/lib/core/save/strategies/mgba_save_strategy.dart
@@ -0,0 +1,68 @@
+import 'dart:io';
+import 'dart:typed_data';
+import '../../romm/romm_models.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for mGBA (GBA/GBC/GB).
+class MgbaSaveStrategy extends SaveStrategy {
+ @override
+ String get strategyId => 'mgba';
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ return File(romPath).parent.path;
+ }
+
+ @override
+ Future<List<File>> getSaveFiles(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
+ final romFile = File(romPath);
+ final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), '');
+ final saveFile = File('${File(romPath).parent.path}/$romStem.sav');
+
+ if (await saveFile.exists()) {
+ if (sessionStart != null) {
+ final stat = await saveFile.stat();
+ if (stat.modified.isBefore(sessionStart)) return [];
+ }
+ return [saveFile];
+ } else {
+ // Fallback to getRomStem(game)
+ final fallbackStem = getRomStem(game);
+ final fallbackSaveFile = File('${File(romPath).parent.path}/$fallbackStem.sav');
+ if (await fallbackSaveFile.exists()) {
+ if (sessionStart != null) {
+ final stat = await fallbackSaveFile.stat();
+ if (stat.modified.isBefore(sessionStart)) return [];
+ }
+ return [fallbackSaveFile];
+ }
+ }
+ return [];
+ }
+
+ @override
+ Future<bool> restoreSave(
+ Game game, String destPath, Uint8List data, String filename) async {
+ try {
+ final romFile = File(destPath);
+ final romStem = romFile.uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), '');
+ final targetPath = '${File(destPath).parent.path}/$romStem.sav';
+
+ if (await File(targetPath).exists()) {
+ await backupSave(targetPath);
+ await File(targetPath).writeAsBytes(data);
+ return true;
+ } else {
+ // Fallback to getRomStem(game)
+ final fallbackStem = getRomStem(game);
+ final fallbackTargetPath = '${File(destPath).parent.path}/$fallbackStem.sav';
+ await backupSave(fallbackTargetPath);
+ await File(fallbackTargetPath).writeAsBytes(data);
+ return true;
+ }
+ } catch (e) {
+ return false;
+ }
+ }
+}
diff --git a/lib/core/save/strategies/pcsx2_save_strategy.dart b/lib/core/save/strategies/pcsx2_save_strategy.dart
index 7a44a61..abc7446 100644
--- a/lib/core/save/strategies/pcsx2_save_strategy.dart
+++ b/lib/core/save/strategies/pcsx2_save_strategy.dart
@@ -39,35 +39,31 @@ class Pcsx2SaveStrategy extends SaveStrategy {
final result = <File>[];
// Memory cards — scan all .ps2 files in memcards folder
- if (syncMode == 'saves' || syncMode == 'both') {
- final memcardsDir = Directory('$exeDir\\memcards');
- if (await memcardsDir.exists()) {
- await for (final entity in memcardsDir.list()) {
- if (entity is! File) continue;
- if (!entity.path.toLowerCase().endsWith('.ps2')) continue;
- if (sessionStart != null) {
- final stat = await entity.stat();
- if (stat.modified.isBefore(sessionStart)) continue;
- }
- result.add(entity);
+ final memcardsDir = Directory('$exeDir\\memcards');
+ if (await memcardsDir.exists()) {
+ await for (final entity in memcardsDir.list()) {
+ if (entity is! File) continue;
+ if (!entity.path.toLowerCase().endsWith('.ps2')) continue;
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
}
+ result.add(entity);
}
}
// Save states — named after ROM stem
- if (syncMode == 'states' || syncMode == 'both') {
- final stem = getRomStem(game);
- final statesDir = Directory('$exeDir\\sstates');
- if (await statesDir.exists()) {
- await for (final entity in statesDir.list()) {
- if (entity is! File) continue;
- if (!entity.path.contains(stem)) continue;
- if (sessionStart != null) {
- final stat = await entity.stat();
- if (stat.modified.isBefore(sessionStart)) continue;
- }
- result.add(entity);
+ final stem = getRomStem(game);
+ final statesDir = Directory('$exeDir\\sstates');
+ if (await statesDir.exists()) {
+ await for (final entity in statesDir.list()) {
+ if (entity is! File) continue;
+ if (!entity.path.contains(stem)) continue;
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
}
+ result.add(entity);
}
}
diff --git a/lib/core/save/strategies/ppsspp_save_strategy.dart b/lib/core/save/strategies/ppsspp_save_strategy.dart
new file mode 100644
index 0000000..b62336f
--- /dev/null
+++ b/lib/core/save/strategies/ppsspp_save_strategy.dart
@@ -0,0 +1,108 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'package:archive/archive_io.dart';
+import '../../romm/romm_models.dart';
+import '../../storage/directory_service.dart';
+import '../save_strategy.dart';
+
+/// Save strategy for PPSSPP (PSP).
+class PpssppSaveStrategy extends SaveStrategy {
+ final DirectoryService _directoryService;
+
+ PpssppSaveStrategy(this._directoryService);
+
+ @override
+ String get strategyId => 'ppsspp';
+
+ Future<String?> _getEmulatorDir() async {
+ final exePath = await _directoryService.findEmulatorExecutable(
+ 'ppsspp', 'PPSSPPWindows64.exe');
+ if (exePath == null) return null;
+ return File(exePath).parent.path.replaceAll('/', '\\');
+ }
+
+ @override
+ Future<String?> getSaveDir(Game game, String romPath) async {
+ final emuDir = await _getEmulatorDir();
+ if (emuDir == null) return null;
+ return '$emuDir\\memstick\\PSP\\SAVEDATA';
+ }
+
+ @override
+ Future<List<File>> getSaveFiles(Game game, String romPath,
+ {DateTime? sessionStart, String syncMode = 'both'}) async {
+ final emuDir = await _getEmulatorDir();
+ if (emuDir == null) return [];
+
+ final result = <File>[];
+ final stem = getRomStem(game);
+
+ final saveDataDir = Directory('$emuDir\\memstick\\PSP\\SAVEDATA');
+ if (await saveDataDir.exists()) {
+ bool hasFiles = false;
+ await for (final _ in saveDataDir.list(recursive: true)) {
+ hasFiles = true;
+ break;
+ }
+ if (hasFiles) {
+ result.add(File(saveDataDir.path));
+ }
+ }
+
+ final statesDir = Directory('$emuDir\\memstick\\PSP\\PPSSPP_STATE');
+ if (await statesDir.exists()) {
+ final stateFile = File('${statesDir.path}\\$stem.ppst');
+ if (await stateFile.exists()) {
+ if (sessionStart == null || (await stateFile.stat()).modified.isAfter(sessionStart)) {
+ result.add(stateFile);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ @override
+ Future<bool> restoreSave(
+ Game game, String destPath, Uint8List data, String filename) async {
+ try {
+ final emuDir = await _getEmulatorDir();
+ if (emuDir == null) return false;
+
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ final targetBaseDir = '$emuDir\\memstick\\PSP\\SAVEDATA';
+ for (final entry in archive) {
+ if (entry.name.contains('.bak')) continue;
+ final entryPath = entry.name.replaceAll('/', '\\');
+ final segments = entryPath.split('\\');
+ final strippedPath = segments.length > 1 ? segments.skip(1).join('\\') : entryPath;
+ if (strippedPath.isEmpty) continue;
+ final targetPath = '$targetBaseDir\\$strippedPath';
+ if (entry.isFile) {
+ await backupSave(targetPath);
+ final outFile = File(targetPath);
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(entry.content as List<int>);
+ } else {
+ await Directory(targetPath).create(recursive: true);
+ }
+ }
+ return true;
+ }
+
+ if (filename.toLowerCase().endsWith('.ppst')) {
+ final targetPath = '$emuDir\\memstick\\PSP\\PPSSPP_STATE\\$filename';
+ await backupSave(targetPath);
+ final outFile = File(targetPath);
+ await outFile.parent.create(recursive: true);
+ await outFile.writeAsBytes(data);
+ return true;
+ }
+
+ return false;
+ } catch (e) {
+ return false;
+ }
+ }
+}
diff --git a/lib/core/save/strategies/retroarch_save_strategy.dart b/lib/core/save/strategies/retroarch_save_strategy.dart
index b9accd9..4767e4e 100644
--- a/lib/core/save/strategies/retroarch_save_strategy.dart
+++ b/lib/core/save/strategies/retroarch_save_strategy.dart
@@ -4,6 +4,7 @@ import 'dart:typed_data';
import '../../romm/romm_models.dart';
import '../../storage/directory_service.dart';
import '../save_strategy.dart';
+import 'package:path/path.dart' as p; // Import path package
/// Save strategy for RetroArch emulator.
///
@@ -17,23 +18,24 @@ class RetroArchSaveStrategy extends SaveStrategy {
@override
String get strategyId => 'retroarch';
+ // _CoreInfo maps platform slugs to RetroArch core info, including save and state directories.
static const Map<String, _CoreInfo> _coreMap = {
- 'gba': _CoreInfo('mgba_libretro', 'mGBA', 'mGBA'),
- 'gbc': _CoreInfo('mgba_libretro', 'mGBA', 'mGBA'),
- 'gb': _CoreInfo('mgba_libretro', 'mGBA', 'mGBA'),
- 'snes': _CoreInfo('snes9x_libretro', 'Snes9X', 'Snes9X'),
- 'nes': _CoreInfo('fceumm_libretro', 'FCEUmm', 'FCEUmm'),
- 'n64': _CoreInfo('mupen64plus_next_libretro', 'Mupen64Plus-Next', 'Mupen64Plus-Next'),
- 'nds': _CoreInfo('desmume2015_libretro', 'DeSmuME 2015', 'DeSmuME 2015'),
- 'psx': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'),
- 'psp': _CoreInfo('ppsspp_libretro', 'PPSSPP/PSP/SAVEDATA', 'PPSSPP'),
- 'dreamcast': _CoreInfo('flycast_libretro', 'Flycast', 'Flycast'),
- 'megadrive': _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'),
- 'dc': _CoreInfo('flycast_libretro', 'Flycast', 'Flycast'),
- 'ps1': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'),
- 'playstation': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'),
- 'md': _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'),
- 'genesis': _CoreInfo('genesis_plus_gx_libretro', 'Genesis Plus GX', 'Genesis Plus GX'),
+ 'gba': _CoreInfo('mgba_libretro', 'GBA', 'States/GBA'),
+ 'gbc': _CoreInfo('mgba_libretro', 'GBA', 'States/GBA'), // mGBA uses the same save folder for GBA/GBC/GB
+ 'gb': _CoreInfo('mgba_libretro', 'GBA', 'States/GBA'),
+ 'snes': _CoreInfo('snes9x_libretro', 'SNES', 'States/SNES'),
+ 'nes': _CoreInfo('fceumm_libretro', 'NES', 'States/NES'),
+ 'n64': _CoreInfo('mupen64plus_next_libretro', 'N64', 'States/N64'),
+ 'nds': _CoreInfo('desmume2015_libretro', 'NDS', 'States/NDS'),
+ 'psx': _CoreInfo('pcsx_rearmed_libretro', 'PSX', 'States/PSX'),
+ 'psp': _CoreInfo('ppsspp_libretro', 'PPSSPP/PSP/SAVEDATA', 'PPSSPP'), // Note: saveFolder is 'PPSSPP/PSP/SAVEDATA', statesFolder is 'PPSSPP'
+ 'playstation': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'), // Assuming this is also PSX and needs save/state dir logic
+ 'playstation-portable': _CoreInfo('ppsspp_libretro', 'PPSSPP/PSP/SAVEDATA', 'PPSSPP'), // Alias for PSP
+ 'dreamcast': _CoreInfo('flycast_libretro', 'Dreamcast', 'States/Dreamcast'),
+ 'dc': _CoreInfo('flycast_libretro', 'Dreamcast', 'States/Dreamcast'), // Alias for Dreamcast
+ 'megadrive': _CoreInfo('genesis_plus_gx_libretro', 'Mega Drive', 'States/Mega Drive'),
+ 'genesis': _CoreInfo('genesis_plus_gx_libretro', 'Mega Drive', 'States/Mega Drive'), // Alias for Mega Drive
+ 'md': _CoreInfo('genesis_plus_gx_libretro', 'Mega Drive', 'States/Mega Drive'), // Alias for Mega Drive
};
@override
@@ -46,7 +48,8 @@ class RetroArchSaveStrategy extends SaveStrategy {
if (exePath == null) return null;
final exeDir = File(exePath).parent.path;
- return '$exeDir/saves/${coreInfo.saveFolder}';
+ // The saveFolder in _coreMap is relative to the RetroArch installation directory.
+ return p.join(exeDir, 'saves', coreInfo.saveFolder);
}
@override
@@ -59,32 +62,63 @@ class RetroArchSaveStrategy extends SaveStrategy {
if (exePath == null) return [];
final exeDir = File(exePath).parent.path;
- final stem = getRomStem(game);
- final candidates = <File>[];
-
- if (syncMode == 'saves' || syncMode == 'both') {
- final savesDir = '$exeDir/saves/${coreInfo.saveFolder}';
- candidates.add(File('$savesDir/$stem.srm'));
+ final stem = getRomStem(game); // Assuming getRomStem is available and correct for generating base filename
+
+ final List<File> filesToReturn = [];
+
+ // Special case for PSP saves
+ if (slug == 'psp' || slug == 'playstation-portable') {
+ if (syncMode == 'saves' || syncMode == 'both') {
+ final pspPath = '$exeDir\\saves\\PPSSPP\\PSP'.replaceAll('/', '\\');
+ final pspDir = Directory(pspPath);
+ if (await pspDir.exists()) {
+ bool hasFiles = false;
+ await for (final _ in pspDir.list(recursive: true)) {
+ hasFiles = true;
+ break;
+ }
+ if (hasFiles) {
+ filesToReturn.add(File(pspPath));
+ }
+ }
+ }
+ } else {
+ // Existing logic for non-PSP saves (e.g., SRM files)
+ if (syncMode == 'saves' || syncMode == 'both') {
+ final savesDir = p.join(exeDir, 'saves', coreInfo.saveFolder);
+ filesToReturn.add(File(p.join(savesDir, '$stem.srm')));
+ }
}
+ // Handle States (regardless of platform)
if (syncMode == 'states' || syncMode == 'both') {
- final statesDir = '$exeDir/states/${coreInfo.statesFolder}';
- candidates.add(File('$statesDir/$stem.state.auto'));
- for (int i = 0; i <= 9; i++) {
- candidates.add(File('$statesDir/$stem.state$i'));
+ final statesDir = p.join(exeDir, 'states', coreInfo.statesFolder);
+
+ // Derive stem from romPath as fallback
+ final romStem = File(romPath).uri.pathSegments.last.replaceAll(RegExp(r'\.[^.]+$'), '');
+
+ // Check state files for both stems
+ for (final checkStem in [stem, romStem]) {
+ filesToReturn.add(File('$statesDir/$checkStem.state.auto'));
+ for (int i = 0; i <= 9; i++) {
+ filesToReturn.add(File('$statesDir/$checkStem.state$i'));
+ }
}
}
- final result = <File>[];
- for (final f in candidates) {
- if (!await f.exists()) continue;
- if (sessionStart != null) {
+ // Filter out non-existent files and apply sessionStart filter
+ final finalResult = <File>[];
+ for (final f in filesToReturn) {
+ final existsAsFile = await f.exists();
+ final existsAsDir = await Directory(f.path).exists();
+ if (!existsAsFile && !existsAsDir) continue;
+ if (sessionStart != null && existsAsFile) {
final stat = await f.stat();
if (stat.modified.isBefore(sessionStart)) continue;
}
- result.add(f);
+ finalResult.add(f);
}
- return result;
+ return finalResult;
}
@override
@@ -100,14 +134,14 @@ class RetroArchSaveStrategy extends SaveStrategy {
final isState = filename.contains('.state');
final targetDir = isState
- ? '$exeDir/states/${coreInfo.statesFolder}'
- : '$exeDir/saves/${coreInfo.saveFolder}';
+ ? p.join(exeDir, 'states', coreInfo.statesFolder)
+ : p.join(exeDir, 'saves', coreInfo.saveFolder);
final dir = Directory(targetDir);
if (!await dir.exists()) await dir.create(recursive: true);
- final targetPath = '$targetDir/$filename';
- await backupSave(targetPath);
+ final targetPath = p.join(targetDir, filename);
+ await backupSave(targetPath); // Backup existing file
await File(targetPath).writeAsBytes(data);
return true;
} catch (e) {
@@ -121,4 +155,4 @@ class _CoreInfo {
final String saveFolder;
final String statesFolder;
const _CoreInfo(this.coreName, this.saveFolder, this.statesFolder);
-}
\ No newline at end of file
+}
diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart
index 90f3fa9..e71e873 100644
--- a/lib/core/storage/directory_service.dart
+++ b/lib/core/storage/directory_service.dart
@@ -36,6 +36,7 @@ class DirectoryService {
late String romsRootPath;
late String emulatorsRootPath;
+ final Map<String, String> _emulatorPathOverrides = {};
DirectoryService();
@@ -45,8 +46,30 @@ class DirectoryService {
emulatorsRootPath = prefs.getString(_emulatorsRootPathKey) ?? _defaultEmulatorsPath;
await _ensureDirectoryExists(romsRootPath);
await _ensureDirectoryExists(emulatorsRootPath);
+ await loadEmulatorPathOverrides();
}
+ Future<void> loadEmulatorPathOverrides() async {
+ final prefs = await SharedPreferences.getInstance();
+ for (final key in prefs.getKeys()) {
+ if (key.startsWith('emu_path_')) {
+ final emuId = key.replaceFirst('emu_path_', '');
+ final path = prefs.getString(key);
+ if (path != null) {
+ _emulatorPathOverrides[emuId] = path;
+ }
+ }
+ }
+ }
+
+ Future<void> setEmulatorPathOverride(String emulatorId, String path) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('emu_path_$emulatorId', path);
+ _emulatorPathOverrides[emulatorId] = path;
+ }
+
+ String? getEmulatorPathOverride(String emulatorId) => _emulatorPathOverrides[emulatorId];
+
Future<void> _ensureDirectoryExists(String path) async {
final directory = Directory(path);
if (!await directory.exists()) {
@@ -152,9 +175,19 @@ class DirectoryService {
return null;
}
if (appData.isEmpty || appData.contains('%APPDATA%')) return null;
+ } else if (defaultTargetPlatform == TargetPlatform.linux) {
+ // Linux: Try to find system 7z
+ try {
+ final result = await Process.run('which', ['7z']);
+ if (result.exitCode == 0) {
+ return result.stdout.toString().trim();
+ }
+ } catch (e) {
+ // ignore
+ }
+ return null;
} else {
- // macOS/Linux: no 7zr needed, system 7z is installable via package managers
- // Return null for now — future: resolve via `which 7z`
+ // macOS: no 7zr needed
return null;
}
@@ -173,6 +206,9 @@ class DirectoryService {
}
Future<String> getEmulatorDirectory(String emulatorId) async {
+ final override = getEmulatorPathOverride(emulatorId);
+ if (override != null) return override;
+
final dirPath = '$emulatorsRootPath/$emulatorId';
await _ensureDirectoryExists(dirPath);
return dirPath;
diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart
index db832c1..6c386bd 100644
--- a/lib/providers/download_provider.dart
+++ b/lib/providers/download_provider.dart
@@ -1,13 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../core/downloader/download_service.dart';
+import '../core/emulator/emulator_download_service.dart';
+import '../core/extraction/extraction_service.dart';
import '../core/romm/romm_models.dart';
import 'romm_provider.dart';
final downloadServiceProvider = FutureProvider<DownloadService?>((ref) async {
final directoryService = await ref.watch(directoryServiceProvider.future);
if (directoryService == null) return null;
- return DownloadService(dio: Dio(), directoryService: directoryService);
+ return DownloadService(
+ dio: Dio(),
+ directoryService: directoryService,
+ extractionService: ExtractionService(directoryService),
+ );
+});
+
+final emulatorDownloadServiceProvider =
+ FutureProvider<EmulatorDownloadService?>((ref) async {
+ final directoryService = await ref.watch(directoryServiceProvider.future);
+ if (directoryService == null) return null;
+ return EmulatorDownloadService(
+ Dio(),
+ directoryService,
+ ExtractionService(directoryService),
+ );
});
final downloadProvider =
@@ -20,7 +37,8 @@ class DownloadNotifier extends StateNotifier<Map<String, DownloadProgress>> {
DownloadNotifier(this._ref) : super({});
- Future<void> startDownload(Game game, String downloadUrl, {Map<String, String>? headers}) async {
+ Future<void> startDownload(Game game, String downloadUrl,
+ {Map<String, String>? headers}) async {
final service = await _ref.read(downloadServiceProvider.future);
if (service == null) return;
service.download(game, downloadUrl, headers: headers).listen((progress) {
@@ -28,6 +46,15 @@ class DownloadNotifier extends StateNotifier<Map<String, DownloadProgress>> {
});
}
+ Future<void> startEmulatorDownload(
+ String emulatorId, String emulatorName) async {
+ final service = await _ref.read(emulatorDownloadServiceProvider.future);
+ if (service == null) return;
+ service.downloadEmulator(emulatorId).listen((progress) {
+ state = {...state, emulatorId: progress};
+ });
+ }
+
void removeDownload(String gameId) {
final newState = Map<String, DownloadProgress>.from(state);
newState.remove(gameId);
diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart
index 7432bda..e394015 100644
--- a/lib/providers/library_provider.dart
+++ b/lib/providers/library_provider.dart
@@ -233,7 +233,6 @@ final platformsProvider = FutureProvider<List<Platform>>((ref) async {
final fresh = await service.getPlatforms();
if (fresh.isNotEmpty) {
await _savePlatformsCache(fresh);
- ref.invalidateSelf();
}
} catch (_) {}
});
@@ -261,7 +260,6 @@ final allGamesProvider = FutureProvider<List<Game>>((ref) async {
final fresh = await service.getAllGames();
if (fresh.isNotEmpty) {
await _saveGamesCache(fresh);
- ref.invalidateSelf();
}
} catch (_) {}
});
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index ff962b6..3924d3a 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -40,11 +40,12 @@ final directoryServiceProvider = FutureProvider<DirectoryService?>((ref) async {
});
// Provider for StrategyRegistry
-final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) {
+final strategyRegistryProvider = FutureProvider<StrategyRegistry?>((ref) async {
final directoryService = ref.watch(directoryServiceProvider).value;
if (directoryService != null) {
try {
final registry = StrategyRegistry(directoryService);
+ await registry.loadPreferences(); // Await preferences loading
// Load persisted Windows exe overrides
final winStrategy = registry.getStrategyForSlug('windows');
if (winStrategy is WindowsStrategy) {
@@ -59,12 +60,12 @@ final strategyRegistryProvider = Provider<StrategyRegistry?>((ref) {
});
// SaveSyncService provider
-final saveSyncServiceProvider = Provider<SaveSyncService?>((ref) {
+final saveSyncServiceProvider = FutureProvider<SaveSyncService?>((ref) async {
final rommService = ref.watch(rommServiceProvider);
final directoryService = ref.watch(directoryServiceProvider).asData?.value;
- if (rommService == null || directoryService == null) return null;
- final service = SaveSyncService(rommService, directoryService);
- // Load persisted Windows save path overrides
+ final strategyRegistry = await ref.watch(strategyRegistryProvider.future);
+ if (rommService == null || directoryService == null || strategyRegistry == null) return null;
+ final service = SaveSyncService(rommService, directoryService, strategyRegistry);
service.windowsSaveStrategy.loadPersistedOverrides();
return service;
});
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index e4379e0..cc7ebe7 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -1,3 +1,7 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -6,11 +10,12 @@ import '../../providers/download_provider.dart';
import '../../providers/romm_provider.dart';
import '../../core/storage/directory_service.dart';
import '../../core/romm/romm_models.dart';
+import '../../core/emulator/strategies/windows_strategy.dart';
+import '../../core/emulator/strategies/retroarch_strategy.dart';
import '../widgets/game_card.dart';
import '../widgets/platform_filter_bar.dart';
import '../widgets/windows_game_config_dialog.dart';
-import '../../core/emulator/strategies/windows_strategy.dart';
-import 'dart:convert';
+import 'library_skeleton.dart';
class LibraryScreen extends ConsumerStatefulWidget {
const LibraryScreen({super.key});
@@ -26,15 +31,12 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
Future<void> _loadDownloadStates(
DirectoryService dirService, List<Game> games) async {
if (_downloadStatesLoaded) return;
-
final results = await Future.wait(
games.map((game) async {
- final isDownloaded =
- await dirService.isRomDownloaded(game);
+ final isDownloaded = await dirService.isRomDownloaded(game);
return MapEntry(game.id, isDownloaded);
}),
);
-
if (mounted) {
setState(() {
_downloadedStates = Map.fromEntries(results);
@@ -53,12 +55,40 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
}
}
- Future<void> _handleLaunch(BuildContext context, WidgetRef ref, game) async {
- final registry = ref.read(strategyRegistryProvider);
- final strategy = registry?.getStrategyForSlug(game.platformSlug ?? '');
+ void _startDownload(BuildContext context, WidgetRef ref, Game game) {
+ final service = ref.read(rommServiceProvider);
+ if (service == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Not connected to RomM')),
+ );
+ return;
+ }
+ final url = service.getDownloadUrl(game);
+ final basicAuth =
+ 'Basic ${base64Encode(utf8.encode('${service.config.username}:${service.config.password}'))}';
+ final headers = <String, String>{'Authorization': basicAuth};
+ ref.read(downloadProvider.notifier).startDownload(game, url, headers: headers);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Downloading ${game.name}...')),
+ );
+ final dirService = ref.read(directoryServiceProvider).asData?.value;
+ if (dirService != null) {
+ Future.delayed(const Duration(seconds: 2), () {
+ _refreshDownloadState(dirService, game);
+ });
+ }
+ }
+
+ Future<void> _handleLaunch(BuildContext context, WidgetRef ref, Game game) async {
+ final messenger = ScaffoldMessenger.of(context);
+
+ // Ensure strategy registry preferences are loaded
+ final registryReady = await ref.read(strategyRegistryProvider.future);
+ if (registryReady == null) return;
+ final strategy = registryReady.getStrategyForSlug(game.platformSlug ?? '');
if (strategy == null) {
- ScaffoldMessenger.of(context).showSnackBar(
+ messenger.showSnackBar(
SnackBar(
content: Text(
'No emulator configured for ${game.platformDisplayName ?? game.platformSlug ?? 'this platform'}')),
@@ -66,10 +96,13 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
return;
}
+ // Wait for save sync service to be ready (FutureProvider chain)
+ final syncService = await ref.read(saveSyncServiceProvider.future);
+
final dir = await ref.read(directoryServiceProvider.future);
if (!context.mounted) return;
if (dir == null) {
- ScaffoldMessenger.of(context).showSnackBar(
+ messenger.showSnackBar(
const SnackBar(content: Text('Storage service not available')),
);
return;
@@ -118,7 +151,6 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
],
),
);
-
if (!context.mounted) return;
if (shouldDownload == true) {
_startDownload(context, ref, game);
@@ -126,18 +158,22 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
return;
}
- // Pull latest cloud save before launching
- final syncService = ref.read(saveSyncServiceProvider);
if (syncService != null) {
- // Push local saves first so nothing is lost, then pull cloud save
final syncMode = ref.read(retroarchSyncModeProvider);
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text('Pushing saves for ${game.name}...'),
+ duration: const Duration(seconds: 30),
+ ),
+ );
await syncService.pushSaves(game, existingRomPath, syncMode: syncMode);
if (!context.mounted) return;
+ messenger.clearSnackBars();
try {
final pulled = await syncService.pullSave(game, existingRomPath);
if (!context.mounted) return;
if (pulled) {
- ScaffoldMessenger.of(context).showSnackBar(
+ messenger.showSnackBar(
const SnackBar(
content: Text('Cloud save restored'),
duration: Duration(seconds: 2)),
@@ -169,9 +205,97 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
}
try {
- await strategy.launch(game, existingRomPath);
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text('Launching ${game.name}...'),
+ duration: const Duration(seconds: 3),
+ ),
+ );
+
+ // Try to get process handle for auto-sync when game closes
+ Process? process = await strategy.launchWithHandle(game, existingRomPath);
+ if (process == null) {
+ // Fall back to regular launch if no process handle available
+ await strategy.launch(game, existingRomPath);
+ } else {
+ // Start background Future to handle process exit
+ unawaited(Future.delayed(Duration.zero, () async {
+ try {
+ await process.exitCode;
+ if (!context.mounted) return;
+
+ messenger.showSnackBar(
+ const SnackBar(
+ content: Text('Auto-syncing saves...'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+
+ if (syncService != null) {
+ final syncMode = ref.read(retroarchSyncModeProvider);
+ await syncService.pushSaves(game, existingRomPath, syncMode: syncMode);
+ }
+
+ if (!context.mounted) return;
+ messenger.showSnackBar(
+ const SnackBar(
+ content: Text('Saves synced'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+ } catch (e) {
+ // Silently ignore errors in auto-sync
+ }
+ }));
+ }
} catch (e) {
if (!context.mounted) return;
+
+ if (e is MissingRetroArchCoreException) {
+ final shouldDownload = await showDialog<bool>(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('RetroArch Core Missing'),
+ content: Text(
+ 'The core ${e.coreName} is required for this game but is not installed. Would you like Freegosy to download and install it automatically?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: const Text('Cancel'),
+ ),
+ ElevatedButton(
+ onPressed: () => Navigator.of(ctx).pop(true),
+ child: const Text('Install'),
+ ),
+ ],
+ ),
+ );
+ if (shouldDownload == true && context.mounted) {
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => const Center(child: CircularProgressIndicator()),
+ );
+ try {
+ final raStrategy = strategy as RetroArchStrategy;
+ final coresDir = File(e.corePath).parent.path;
+ await raStrategy.downloadCore(e.coreName, coresDir, Dio());
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ await _handleLaunch(context, ref, game);
+ }
+ } catch (err) {
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ messenger.showSnackBar(
+ SnackBar(content: Text('Failed to download core: $err')),
+ );
+ }
+ }
+ }
+ return;
+ }
+
final isWindows =
['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
final isMissingExe =
@@ -179,7 +303,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
if (isWindows && isMissingExe) {
await _handleWindowsConfig(context, ref, game);
} else {
- ScaffoldMessenger.of(context).showSnackBar(
+ messenger.showSnackBar(
SnackBar(
content: Text('Launch failed: $e'),
duration: const Duration(seconds: 8),
@@ -191,13 +315,13 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
Future<void> _handleWindowsConfig(
BuildContext context, WidgetRef ref, Game game) async {
- final registry = ref.read(strategyRegistryProvider);
+ final registry = ref.read(strategyRegistryProvider).asData?.value;
final windowsStrategy =
registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?;
- final syncService = ref.read(saveSyncServiceProvider);
-
- final result = await showDialog<Map<String, String>>(
- context: context,
+ final syncService = await ref.read(saveSyncServiceProvider.future);
+ if (!context.mounted) return;
+ final result = await showDialog<Map<String, String>>(
+ context: context,
builder: (ctx) => WindowsGameConfigDialog(
game: game,
currentExePath: windowsStrategy?.getExeOverride(game.id),
@@ -205,44 +329,40 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
syncService?.windowsSaveStrategy.getManualOverride(game.id),
),
);
- if (result == null) return; // user cancelled
-
+ if (result == null) return;
final exe = result['exe'] ?? '';
final save = result['save'] ?? '';
-
if (exe.isNotEmpty) {
await windowsStrategy?.setExeOverride(game.id, exe);
}
if (save.isNotEmpty) {
await syncService?.windowsSaveStrategy.setManualOverride(game.id, save);
}
-
if (!context.mounted) return;
await _handleLaunch(context, ref, game);
}
Future<void> _handleSyncSaves(
BuildContext context, WidgetRef ref, Game game) async {
- final syncService = ref.read(saveSyncServiceProvider);
+ final messenger = ScaffoldMessenger.of(context);
+
+ final syncService = await ref.read(saveSyncServiceProvider.future);
if (syncService == null) {
- ScaffoldMessenger.of(context).showSnackBar(
+ messenger.showSnackBar(
const SnackBar(content: Text('Save sync not available')),
);
return;
}
-
final dir = ref.read(directoryServiceProvider).asData?.value;
final romPath = dir != null ? await dir.getRomFilePath(game) : '';
-
if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
+ messenger.showSnackBar(
SnackBar(content: Text('Syncing saves for ${game.name}...')),
);
-
final syncMode = ref.read(retroarchSyncModeProvider);
final ok = await syncService.pushSaves(game, romPath, syncMode: syncMode);
if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
+ messenger.showSnackBar(
SnackBar(
content: Text(ok
? 'Saves uploaded for ${game.name}'
@@ -251,63 +371,6 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
);
}
- void _startDownload(BuildContext context, WidgetRef ref, game) {
- final service = ref.read(rommServiceProvider);
- if (service == null) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Not connected to RomM')),
- );
- return;
- }
- final url = service.getDownloadUrl(game);
- final basicAuth =
- 'Basic ${base64Encode(utf8.encode('${service.config.username}:${service.config.password}'))}';
- final headers = <String, String>{'Authorization': basicAuth};
- ref
- .read(downloadProvider.notifier)
- .startDownload(game, url, headers: headers);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Downloading ${game.name}...')),
- );
-
- final dirService = ref.read(directoryServiceProvider).asData?.value;
- if (dirService != null) {
- Future.delayed(const Duration(seconds: 2), () {
- _refreshDownloadState(dirService, game);
- });
- }
- }
-
- double _calculateCardHeight(int columnCount, double cardSpacing,
- double cardAspectRatio, BuildContext context) {
- final screenWidth = MediaQuery.of(context).size.width;
- const padding = 24.0;
- final totalSpacing = cardSpacing * (columnCount - 1);
- final cardWidth = (screenWidth - padding - totalSpacing) / columnCount;
- final safeRatio = cardAspectRatio <= 0 ? 0.56 : cardAspectRatio;
- final coverHeight = cardWidth / safeRatio;
- final totalHeight = coverHeight + 90.0;
- return totalHeight.clamp(100.0, 900.0);
- }
-
- Widget _buildSkeletonGrid(
- double cardAspectRatio, int columnCount, double cardSpacing) {
- return GridView.builder(
- padding: const EdgeInsets.all(12),
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: columnCount,
- crossAxisSpacing: cardSpacing,
- mainAxisSpacing: cardSpacing,
- mainAxisExtent: _calculateCardHeight(
- columnCount, cardSpacing, cardAspectRatio, context),
- ),
- itemCount: 20,
- itemBuilder: (context, index) {
- return _SkeletonCard();
- },
- );
- }
-
@override
Widget build(BuildContext context) {
final platformsAsync = ref.watch(platformsProvider);
@@ -323,7 +386,6 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
final rommConfigAsync = ref.watch(rommConfigProvider);
final directoryServiceAsync = ref.watch(directoryServiceProvider);
- // Build AppBar title: "Freegosy • hostname • N games"
final appBarTitle = rommConfigAsync.when(
data: (config) {
final uri = Uri.tryParse(config.baseUrl);
@@ -341,220 +403,157 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
body: ExcludeSemantics(
child: Column(
children: [
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
- child: TextField(
- decoration: InputDecoration(
- hintText: 'Search games...',
- prefixIcon: const Icon(Icons.search),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8.0),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
+ child: TextField(
+ decoration: InputDecoration(
+ hintText: 'Search games...',
+ prefixIcon: const Icon(Icons.search),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ ),
),
+ onChanged: (value) {
+ ref.read(searchQueryProvider.notifier).state = value;
+ },
),
- onChanged: (value) {
- ref.read(searchQueryProvider.notifier).state = value;
- },
),
- ),
- platformsAsync.when(
- data: (platforms) => PlatformFilterBar(
- platforms: platforms,
- selectedPlatformId: selectedPlatformId,
- onSelected: (platform) {
- ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
- },
+ platformsAsync.when(
+ data: (platforms) => PlatformFilterBar(
+ platforms: platforms,
+ selectedPlatformId: selectedPlatformId,
+ onSelected: (platform) {
+ ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
+ },
+ ),
+ loading: () => const LinearProgressIndicator(),
+ error: (e, s) => Text('Error loading platforms: $e'),
),
- loading: () => const LinearProgressIndicator(),
- error: (e, s) => Text('Error loading platforms: $e'),
- ),
- Expanded(
- child: gamesAsync.when(
- loading: () =>
- _buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing),
- error: (e, s) => Center(
- child: Padding(
- padding: const EdgeInsets.all(16.0),
- child: Text(
- 'Error loading games: $e',
- textAlign: TextAlign.center,
- style: const TextStyle(color: Colors.red),
+ Expanded(
+ child: gamesAsync.when(
+ loading: () => buildSkeletonGrid(cardAspectRatio, columnCount, cardSpacing, context),
+ error: (e, s) => Center(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Text(
+ 'Error loading games: $e',
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.red),
+ ),
),
),
- ),
- data: (_) {
- final gamesCount = filteredGames.length;
- final countDisplayText =
- (selectedPlatformId == null && searchQuery.isEmpty)
- ? 'Showing all $gamesCount games'
- : 'Showing $gamesCount games';
-
- final dirService = directoryServiceAsync.asData?.value;
- if (dirService != null && !_downloadStatesLoaded) {
- final games = ref.read(allGamesProvider).asData?.value ?? [];
- _loadDownloadStates(dirService, games);
- }
-
- return Column(
- children: [
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0),
- child: Align(
- alignment: Alignment.centerRight,
- child: Text(
- countDisplayText,
- style: const TextStyle(fontSize: 12, color: Colors.grey),
+ data: (_) {
+ final gamesCount = filteredGames.length;
+ final countDisplayText =
+ (selectedPlatformId == null && searchQuery.isEmpty)
+ ? 'Showing all $gamesCount games'
+ : 'Showing $gamesCount games';
+
+ final dirService = directoryServiceAsync.asData?.value;
+ if (dirService != null && !_downloadStatesLoaded) {
+ final games = ref.read(allGamesProvider).asData?.value ?? [];
+ _loadDownloadStates(dirService, games);
+ }
+
+ return Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0),
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: Text(
+ countDisplayText,
+ style: const TextStyle(fontSize: 12, color: Colors.grey),
+ ),
),
),
- ),
- Expanded(
- child: RefreshIndicator(
- onRefresh: () async {
- setState(() {
- _downloadStatesLoaded = false;
- _downloadedStates = {};
- });
- final prefs = await SharedPreferences.getInstance();
- await prefs.remove('cached_games');
- await prefs.remove('cached_platforms');
- await prefs.remove('cached_games_time');
- await prefs.remove('cached_platforms_time');
- await prefs.remove('cache_size_exceeded'); // Added this line
- ref.invalidate(allGamesProvider);
- ref.invalidate(platformsProvider);
- await ref.read(allGamesProvider.future);
- },
- child: filteredGames.isEmpty
- ? const CustomScrollView(
- slivers: [
- SliverFillRemaining(
- child:
- Center(child: Text('No games found')),
+ Expanded(
+ child: RefreshIndicator(
+ onRefresh: () async {
+ setState(() {
+ _downloadStatesLoaded = false;
+ _downloadedStates = {};
+ });
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.remove('cached_games');
+ await prefs.remove('cached_platforms');
+ await prefs.remove('cached_games_time');
+ await prefs.remove('cached_platforms_time');
+ await prefs.remove('cache_size_exceeded');
+ ref.invalidate(allGamesProvider);
+ ref.invalidate(platformsProvider);
+ await ref.read(allGamesProvider.future);
+ },
+ child: filteredGames.isEmpty
+ ? const CustomScrollView(
+ slivers: [
+ SliverFillRemaining(
+ child: Center(child: Text('No games found')),
+ ),
+ ],
+ )
+ : GridView.builder(
+ padding: const EdgeInsets.all(12),
+ cacheExtent: 800,
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: columnCount,
+ crossAxisSpacing: cardSpacing,
+ mainAxisSpacing: cardSpacing,
+ mainAxisExtent: calculateCardHeight(
+ columnCount, cardSpacing, cardAspectRatio, context),
),
- ],
- )
- : GridView.builder(
- padding: const EdgeInsets.all(12),
- gridDelegate:
- SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: columnCount,
- crossAxisSpacing: cardSpacing,
- mainAxisSpacing: cardSpacing,
- mainAxisExtent: _calculateCardHeight(
- columnCount,
- cardSpacing,
- cardAspectRatio,
- context),
- ),
- itemCount: filteredGames.length,
- itemBuilder: (context, index) {
- final game = filteredGames[index];
- final dirService =
- directoryServiceAsync.asData?.value;
- final isWindowsGame = [
- 'windows',
- 'pc',
- 'win'
- ].contains(
- game.platformSlug?.toLowerCase() ?? '');
- if (dirService == null) {
+ itemCount: filteredGames.length,
+ itemBuilder: (context, index) {
+ final game = filteredGames[index];
+ final dirService = directoryServiceAsync.asData?.value;
+ final isWindowsGame = ['windows', 'pc', 'win']
+ .contains(game.platformSlug?.toLowerCase() ?? '');
+ final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
+
+ if (dirService == null) {
+ return GestureDetector(
+ onLongPress: isWindowsGame
+ ? () => _handleWindowsConfig(context, ref, game)
+ : null,
+ child: GameCard(
+ game: game,
+ coverUrl: coverUrl,
+ showTitle: showTitle,
+ showButtonsOnHover: showButtonsOnHover,
+ onDownload: () => _startDownload(context, ref, game),
+ onLaunch: () => _handleLaunch(context, ref, game),
+ onSyncSaves: () => _handleSyncSaves(context, ref, game),
+ ),
+ );
+ }
+
return GestureDetector(
onLongPress: isWindowsGame
- ? () => _handleWindowsConfig(
- context, ref, game)
+ ? () => _handleWindowsConfig(context, ref, game)
: null,
child: GameCard(
game: game,
+ coverUrl: coverUrl,
+ isDownloaded: _downloadedStates[game.id] ?? false,
showTitle: showTitle,
showButtonsOnHover: showButtonsOnHover,
- onDownload: () =>
- _startDownload(context, ref, game),
- onLaunch: () =>
- _handleLaunch(context, ref, game),
- onSyncSaves: () =>
- _handleSyncSaves(context, ref, game),
+ onDownload: () => _startDownload(context, ref, game),
+ onLaunch: () => _handleLaunch(context, ref, game),
+ onSyncSaves: () => _handleSyncSaves(context, ref, game),
),
);
- }
-
- return GestureDetector(
- onLongPress: isWindowsGame
- ? () => _handleWindowsConfig(
- context, ref, game)
- : null,
- child: GameCard(
- game: game,
- isDownloaded: _downloadedStates[game.id] ?? false,
- showTitle: showTitle,
- showButtonsOnHover: showButtonsOnHover,
- onDownload: () => _startDownload(
- context, ref, game),
- onLaunch: () =>
- _handleLaunch(context, ref, game),
- onSyncSaves: () => _handleSyncSaves(
- context, ref, game),
- ),
- );
- },
- ),
+ },
+ ),
+ ),
),
- ),
- ],
- );
- },
+ ],
+ );
+ },
+ ),
),
- ),
- ],
- ),
+ ],
+ ),
),
);
}
-}
-
-class _SkeletonCard extends StatefulWidget {
- @override
- State<_SkeletonCard> createState() => _SkeletonCardState();
-}
-
-class _SkeletonCardState extends State<_SkeletonCard>
- with SingleTickerProviderStateMixin {
- late AnimationController _controller;
- late Animation<double> _animation;
-
- @override
- void initState() {
- super.initState();
- _controller = AnimationController(
- vsync: this,
- duration: const Duration(milliseconds: 1200),
- )..repeat(reverse: true);
- _animation = CurvedAnimation(
- parent: _controller,
- curve: Curves.easeInOut,
- );
- }
-
- @override
- void dispose() {
- _controller.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- return AnimatedBuilder(
- animation: _animation,
- builder: (context, child) {
- return Container(
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- color: Color.lerp(
- const Color(0xFF1a1a1a),
- const Color(0xFF2a2a2a),
- _animation.value,
- ),
- ),
- );
- },
- );
- }
-}
+}
\ No newline at end of file
diff --git a/lib/ui/screens/library_skeleton.dart b/lib/ui/screens/library_skeleton.dart
new file mode 100644
index 0000000..45840c9
--- /dev/null
+++ b/lib/ui/screens/library_skeleton.dart
@@ -0,0 +1,91 @@
+import 'package:flutter/material.dart';
+
+double calculateCardHeight(int columnCount, double cardSpacing,
+ double cardAspectRatio, BuildContext context) {
+ final screenWidth = MediaQuery.of(context).size.width;
+ const padding = 24.0;
+ final totalSpacing = cardSpacing * (columnCount - 1);
+ final cardWidth = (screenWidth - padding - totalSpacing) / columnCount;
+ final safeRatio = cardAspectRatio <= 0 ? 0.56 : cardAspectRatio;
+ final coverHeight = cardWidth / safeRatio;
+ final totalHeight = coverHeight + 90.0;
+ return totalHeight.clamp(100.0, 900.0);
+}
+
+Widget buildSkeletonGrid(
+ double cardAspectRatio, int columnCount, double cardSpacing, BuildContext context) {
+ return GridView.builder(
+ padding: const EdgeInsets.all(12),
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: columnCount,
+ crossAxisSpacing: cardSpacing,
+ mainAxisSpacing: cardSpacing,
+ mainAxisExtent: calculateCardHeight(columnCount, cardSpacing, cardAspectRatio, context),
+ ),
+ itemCount: 20,
+ itemBuilder: (context, index) {
+ return const _SkeletonCard();
+ },
+ );
+}
+
+class _SkeletonCard extends StatefulWidget {
+ const _SkeletonCard();
+
+ @override
+ State<_SkeletonCard> createState() => _SkeletonCardState();
+}
+
+class _SkeletonCardState extends State<_SkeletonCard>
+ with SingleTickerProviderStateMixin {
+ late AnimationController _controller;
+ late Animation<double> _animation;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 1200),
+ );
+ _animation = CurvedAnimation(
+ parent: _controller,
+ curve: Curves.easeInOut,
+ );
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (TickerMode.valuesOf(context).enabled) {
+ _controller.repeat(reverse: true);
+ } else {
+ _controller.stop();
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: _animation,
+ builder: (context, child) {
+ return Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ color: Color.lerp(
+ const Color(0xFF1a1a1a),
+ const Color(0xFF2a2a2a),
+ _animation.value,
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/ui/screens/settings_display_section.dart b/lib/ui/screens/settings_display_section.dart
new file mode 100644
index 0000000..ffd9574
--- /dev/null
+++ b/lib/ui/screens/settings_display_section.dart
@@ -0,0 +1,165 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../../providers/library_provider.dart'; // Assuming this file contains the providers and kDisplayPresets
+
+// NOTE: kDisplayPresets is assumed to be globally available or defined in library_provider.dart
+// If not, it needs to be imported or passed as an argument.
+
+// Function to build the Library Display section
+Widget buildDisplaySection(
+ BuildContext context,
+ double cardAspectRatio,
+ int columnCount,
+ double cardSpacing,
+ bool showTitle,
+ bool showButtonsOnHover,
+ String activePreset,
+ WidgetRef ref,
+) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Library Display', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 12),
+ const Text('Presets', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8,
+ children: [
+ _presetChip('Windows', 'windows_best', activePreset, ref),
+ _presetChip('Steam Deck', 'steamdeck_best', activePreset, ref),
+ _presetChip('Cozy', 'cozy', activePreset, ref),
+ _presetChip('Compact', 'compact', activePreset, ref),
+ _presetChip('Custom', 'custom', activePreset, ref),
+ ],
+ ),
+ const SizedBox(height: 24),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text('Columns per row', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
+ Text('$columnCount', style: const TextStyle(fontSize: 16, color: Colors.deepPurple)),
+ ],
+ ),
+ Slider(
+ value: columnCount.toDouble(),
+ min: 2,
+ max: 8,
+ divisions: 6,
+ label: '$columnCount',
+ onChanged: (value) async {
+ ref.read(activePresetProvider.notifier).state = 'custom';
+ ref.read(columnCountProvider.notifier).state = value.toInt();
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setInt('column_count', value.toInt());
+ await prefs.setString('active_preset', 'custom');
+ },
+ ),
+ const SizedBox(height: 16),
+ const Text('Card Shape', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 8),
+ SegmentedButton<double>(
+ segments: const [
+ ButtonSegment(value: 1.0, label: Text('Square')),
+ ButtonSegment(value: 0.72, label: Text('Portrait')),
+ ButtonSegment(value: 0.58, label: Text('Tall')),
+ ],
+ selected: {
+ [1.0, 0.72, 0.58].reduce((a, b) =>
+ (a - cardAspectRatio).abs() < (b - cardAspectRatio).abs()
+ ? a
+ : b)
+ },
+ onSelectionChanged: (selection) async {
+ ref.read(activePresetProvider.notifier).state = 'custom';
+ ref.read(cardAspectRatioProvider.notifier).state = selection.first;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setDouble('card_aspect_ratio', selection.first);
+ await prefs.setString('active_preset', 'custom');
+ },
+ ),
+ const SizedBox(height: 16),
+ const Text('Card Spacing', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 8),
+ SegmentedButton<double>(
+ segments: const [
+ ButtonSegment(value: 4.0, label: Text('Tight')),
+ ButtonSegment(value: 8.0, label: Text('Normal')),
+ ButtonSegment(value: 12.0, label: Text('Airy')),
+ ],
+ selected: {
+ [4.0, 8.0, 12.0].reduce((a, b) =>
+ (a - cardSpacing).abs() < (b - cardSpacing).abs() ? a : b)
+ },
+ onSelectionChanged: (selection) async {
+ ref.read(activePresetProvider.notifier).state = 'custom';
+ ref.read(cardSpacingProvider.notifier).state = selection.first;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setDouble('card_spacing', selection.first);
+ await prefs.setString('active_preset', 'custom');
+ },
+ ),
+ const SizedBox(height: 16),
+ SwitchListTile(
+ title: const Text('Show game title'),
+ subtitle: const Text('Display title text below cover art'),
+ value: showTitle,
+ contentPadding: EdgeInsets.zero,
+ onChanged: (value) async {
+ ref.read(activePresetProvider.notifier).state = 'custom';
+ ref.read(showTitleProvider.notifier).state = value;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool('show_title', value);
+ await prefs.setString('active_preset', 'custom');
+ },
+ ),
+ SwitchListTile(
+ title: const Text('Show buttons on hover only'),
+ subtitle: const Text('Buttons appear when hovering over a card'),
+ value: showButtonsOnHover,
+ contentPadding: EdgeInsets.zero,
+ onChanged: (value) async {
+ ref.read(activePresetProvider.notifier).state = 'custom';
+ ref.read(showButtonsOnHoverProvider.notifier).state = value;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool('show_buttons_on_hover', value);
+ await prefs.setString('active_preset', 'custom');
+ },
+ ),
+ ],
+ );
+}
+
+// Helper widget for preset chips
+Widget _presetChip(String label, String presetKey, String activePreset, WidgetRef ref) {
+ final isSelected = activePreset == presetKey;
+ return FilterChip(
+ label: Text(label),
+ selected: isSelected,
+ onSelected: (selected) async {
+ if (!selected) return;
+ ref.read(activePresetProvider.notifier).state = presetKey;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('active_preset', presetKey);
+ if (presetKey == 'custom') return;
+ final preset = kDisplayPresets[presetKey]; // kDisplayPresets must be accessible
+ if (preset == null) return;
+ final cols = preset['columnCount'] as int;
+ final ratio = preset['cardAspectRatio'] as double;
+ final spacing = preset['cardSpacing'] as double;
+ final title = preset['showTitle'] as bool;
+ final hover = preset['showButtonsOnHover'] as bool;
+ ref.read(columnCountProvider.notifier).state = cols;
+ ref.read(cardAspectRatioProvider.notifier).state = ratio;
+ ref.read(cardSpacingProvider.notifier).state = spacing;
+ ref.read(showTitleProvider.notifier).state = title;
+ ref.read(showButtonsOnHoverProvider.notifier).state = hover;
+ await prefs.setInt('column_count', cols);
+ await prefs.setDouble('card_aspect_ratio', ratio);
+ await prefs.setDouble('card_spacing', spacing);
+ await prefs.setBool('show_title', title);
+ await prefs.setBool('show_buttons_on_hover', hover);
+ },
+ );
+}
diff --git a/lib/ui/screens/settings_emulators_section.dart b/lib/ui/screens/settings_emulators_section.dart
new file mode 100644
index 0000000..4fc673a
--- /dev/null
+++ b/lib/ui/screens/settings_emulators_section.dart
@@ -0,0 +1,158 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:file_picker/file_picker.dart';
+import '../../core/storage/directory_service.dart';
+import '../../core/emulator/emulator_registry_data.dart';
+import '../../core/emulator/strategy_registry.dart';
+import '../../providers/download_provider.dart';
+
+// Function to build the Emulators section
+Widget buildEmulatorsSection(
+ BuildContext context,
+ DirectoryService directoryService,
+ bool emulatorsLoaded,
+ Map<String, bool> emulatorInstallStates,
+ Function(void Function()) setState,
+ WidgetRef ref,
+) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Emulators',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 16),
+ if (!emulatorsLoaded)
+ const Center(child: CircularProgressIndicator())
+ else
+ ...kEmulatorDefinitions.map<Widget>((def) {
+ final emulatorId = def['id'] as String;
+ final emulatorName = def['name'] as String;
+ final isInstalled = emulatorInstallStates[emulatorId] ?? false;
+ final overridePath = directoryService.getEmulatorPathOverride(emulatorId);
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
+ child: Row(
+ children: [
+ Icon(
+ isInstalled ? Icons.check_circle : Icons.cancel,
+ color: isInstalled ? Colors.green : Colors.red,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(emulatorName),
+ if (overridePath != null)
+ Text(
+ overridePath,
+ style: const TextStyle(fontSize: 11, color: Colors.grey),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.folder_open, size: 20),
+ tooltip: 'Set custom directory',
+ onPressed: () async {
+ String? selectedDirectory = await FilePicker.platform.getDirectoryPath();
+ if (selectedDirectory != null) {
+ await directoryService.setEmulatorPathOverride(emulatorId, selectedDirectory);
+ setState(() {}); // Trigger parent state update
+ }
+ },
+ ),
+ ElevatedButton(
+ onPressed: isInstalled
+ ? null
+ : () async {
+ // Capture context safely before any async operations.
+ final messenger = ScaffoldMessenger.of(context);
+
+ messenger.showSnackBar(SnackBar(
+ content: Text('Starting download for $emulatorName...'),
+ ));
+
+ ref.read(downloadProvider.notifier).startEmulatorDownload(emulatorId, emulatorName);
+
+ // Listen for download completion and update state
+ ref.read(downloadProvider.notifier).stream.listen((downloads) {
+ final progress = downloads[emulatorId];
+ if (progress != null && progress.isComplete) { // Use safeContext here
+ // Update the map directly. setState will re-render using the updated map.
+ emulatorInstallStates[emulatorId] = true;
+ setState(() {}); // Trigger parent state update
+ messenger.showSnackBar(SnackBar(
+ content: Text('$emulatorName downloaded.'),
+ ));
+ }
+ });
+ },
+ child: Text(isInstalled ? 'Installed' : 'Download'),
+ ),
+ ],
+ ),
+ );
+ }),
+ ],
+ );
+}
+
+// Function to build the Emulator Conflicts section
+Widget buildConflictsSection(
+ StrategyRegistry registry,
+ Function(void Function()) setState,
+) {
+ final conflicts = registry.detectConflicts();
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Emulator Conflicts',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 16),
+ if (conflicts.isEmpty)
+ const Text('No conflicts detected')
+ else
+ ...conflicts.entries.map((entry) {
+ final slug = entry.key;
+ final strategies = entry.value;
+ final currentStrategy = registry.getStrategyForSlug(slug);
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(slug, style: const TextStyle(fontWeight: FontWeight.w500)),
+ Text(strategies.map((s) => s.name).join(' vs '),
+ style: const TextStyle(fontSize: 12, color: Colors.grey)),
+ ],
+ ),
+ ),
+ DropdownButton<String>(
+ value: currentStrategy?.emulatorId,
+ items: strategies.map((s) {
+ return DropdownMenuItem(
+ value: s.emulatorId,
+ child: Text(s.name),
+ );
+ }).toList(),
+ onChanged: (value) async {
+ if (value != null) {
+ await registry.setPreference(slug, value);
+ setState(() {}); // Trigger parent state update
+ }
+ },
+ ),
+ ],
+ ),
+ );
+ }),
+ ],
+ );
+}
diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart
index 55c92dd..74fddff 100644
--- a/lib/ui/screens/settings_screen.dart
+++ b/lib/ui/screens/settings_screen.dart
@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
-import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
-
import '../../core/storage/directory_service.dart';
import '../../core/emulator/emulator_registry_data.dart';
-import '../../core/emulator/emulator_download_service.dart';
+// import '../../core/extraction/extraction_service.dart'; // Removed unused import
import '../../providers/romm_provider.dart';
-import '../../providers/library_provider.dart';
+import '../../providers/library_provider.dart'; // Assuming this file contains the display providers and kDisplayPresets
import '../../core/romm/romm_service.dart';
+import 'settings_emulators_section.dart';
+import 'settings_display_section.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@@ -24,7 +24,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late TextEditingController _passwordController;
bool _isSaving = false;
Map<String, bool> _emulatorInstallStates = {};
- bool _emulatorsLoaded = false;
+ bool _emulatorsLoaded = false; // This state is managed here
+ bool _preferencesLoaded = false;
@override
void initState() {
@@ -32,6 +33,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_baseUrlController = TextEditingController();
_usernameController = TextEditingController();
_passwordController = TextEditingController();
+
+ // Mark preferences as loaded (they are now awaited by the provider)
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ final registry = ref.read(strategyRegistryProvider).asData?.value;
+ if (registry != null && !_preferencesLoaded) {
+ if (mounted) setState(() => _preferencesLoaded = true);
+ }
+ });
}
@override
@@ -42,6 +51,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
super.dispose();
}
+ // This method should remain in the state class to manage its own state.
Future<void> _loadEmulatorStates(DirectoryService directoryService) async {
if (_emulatorsLoaded) return;
final states = <String, bool>{};
@@ -49,9 +59,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final id = def['id'] as String;
final exe = def['windows_executable'] as String;
if (exe.isEmpty) {
- states[id] = true;
+ states[id] = true; // Assume installed if no executable is defined
continue;
}
+ // Check if emulator is installed using the directory service
states[id] = await directoryService.isEmulatorInstalled(id, exe);
}
if (mounted) {
@@ -67,7 +78,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final directoryServiceAsync = ref.watch(directoryServiceProvider);
final rommService = ref.watch(rommServiceProvider);
final rommConfigAsync = ref.watch(rommConfigProvider);
+ final strategyRegistry = ref.watch(strategyRegistryProvider).asData?.value;
+ // Display section providers
final cardAspectRatio = ref.watch(cardAspectRatioProvider);
final columnCount = ref.watch(columnCountProvider);
final cardSpacing = ref.watch(cardSpacingProvider);
@@ -75,10 +88,16 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final showButtonsOnHover = ref.watch(showButtonsOnHoverProvider);
final activePreset = ref.watch(activePresetProvider);
+ // Mark preferences as loaded (they are awaited by the provider)
+ if (strategyRegistry != null && !_preferencesLoaded) {
+ if (mounted) setState(() => _preferencesLoaded = true);
+ }
+
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: rommConfigAsync.when(
data: (rommConfig) {
+ // Update controllers with loaded config
_baseUrlController.text = rommConfig.baseUrl;
_usernameController.text = rommConfig.username;
_passwordController.text = rommConfig.password;
@@ -92,6 +111,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
return const Center(child: CircularProgressIndicator());
}
+ // Load emulator states if directory service is available
_loadEmulatorStates(directoryService);
return ExcludeSemantics(
@@ -100,7 +120,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
children: [
_buildRommServerSection(context, ref, rommService),
const SizedBox(height: 24),
- _buildDisplaySection(
+ // Call the extracted display section function
+ buildDisplaySection(
context,
cardAspectRatio,
columnCount,
@@ -108,13 +129,30 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
showTitle,
showButtonsOnHover,
activePreset,
+ ref, // Pass ref
),
const SizedBox(height: 24),
_buildStorageSection(directoryService),
const SizedBox(height: 24),
_buildRetroArchSyncModeSection(context, ref),
const SizedBox(height: 24),
- _buildEmulatorsSection(directoryService),
+ // Call the extracted emulators section function
+ buildEmulatorsSection(
+ context, // Pass context
+ directoryService,
+ _emulatorsLoaded, // Pass loaded state
+ _emulatorInstallStates, // Pass the states map
+ setState, // Pass the setState callback
+ ref, // Pass ref
+ ),
+ if (strategyRegistry != null) ...[
+ const SizedBox(height: 24),
+ // Call the extracted conflicts section function
+ buildConflictsSection(
+ strategyRegistry,
+ setState, // Pass the setState callback
+ ),
+ ],
],
),
);
@@ -129,188 +167,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
- Widget _buildDisplaySection(
- BuildContext context,
- double cardAspectRatio,
- int columnCount,
- double cardSpacing,
- bool showTitle,
- bool showButtonsOnHover,
- String activePreset,
- ) {
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Text('Library Display', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
- const SizedBox(height: 12),
- const Text('Presets', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
- const SizedBox(height: 8),
- Wrap(
- spacing: 8,
- children: [
- _presetChip('Windows', 'windows_best', activePreset),
- _presetChip('Steam Deck', 'steamdeck_best', activePreset),
- _presetChip('Cozy', 'cozy', activePreset),
- _presetChip('Compact', 'compact', activePreset),
- _presetChip('Custom', 'custom', activePreset),
- ],
- ),
- const SizedBox(height: 24),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- const Text('Columns per row', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
- Text('$columnCount', style: const TextStyle(fontSize: 16, color: Colors.deepPurple)),
- ],
- ),
- Slider(
- value: columnCount.toDouble(),
- min: 2,
- max: 8,
- divisions: 6,
- label: '$columnCount',
- onChanged: (value) async {
- ref.read(activePresetProvider.notifier).state = 'custom';
- ref.read(columnCountProvider.notifier).state = value.toInt();
- final prefs = await SharedPreferences.getInstance();
- await prefs.setInt('column_count', value.toInt());
- await prefs.setString('active_preset', 'custom');
- },
- ),
- const SizedBox(height: 16),
- const Text('Card Shape', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
- const SizedBox(height: 8),
- SegmentedButton<double>(
- segments: const [
- ButtonSegment(value: 1.0, label: Text('Square')),
- ButtonSegment(value: 0.72, label: Text('Portrait')),
- ButtonSegment(value: 0.58, label: Text('Tall')),
- ],
- selected: {
- [1.0, 0.72, 0.58].reduce((a, b) =>
- (a - cardAspectRatio).abs() < (b - cardAspectRatio).abs()
- ? a
- : b)
- },
- onSelectionChanged: (selection) async {
- ref.read(activePresetProvider.notifier).state = 'custom';
- ref.read(cardAspectRatioProvider.notifier).state = selection.first;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setDouble('card_aspect_ratio', selection.first);
- await prefs.setString('active_preset', 'custom');
- },
- ),
- const SizedBox(height: 16),
- const Text('Card Spacing', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
- const SizedBox(height: 8),
- SegmentedButton<double>(
- segments: const [
- ButtonSegment(value: 4.0, label: Text('Tight')),
- ButtonSegment(value: 8.0, label: Text('Normal')),
- ButtonSegment(value: 12.0, label: Text('Airy')),
- ],
- selected: {
- [4.0, 8.0, 12.0].reduce((a, b) =>
- (a - cardSpacing).abs() < (b - cardSpacing).abs() ? a : b)
- },
- onSelectionChanged: (selection) async {
- ref.read(activePresetProvider.notifier).state = 'custom';
- ref.read(cardSpacingProvider.notifier).state = selection.first;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setDouble('card_spacing', selection.first);
- await prefs.setString('active_preset', 'custom');
- },
- ),
- const SizedBox(height: 16),
- SwitchListTile(
- title: const Text('Show game title'),
- subtitle: const Text('Display title text below cover art'),
- value: showTitle,
- contentPadding: EdgeInsets.zero,
- onChanged: (value) async {
- ref.read(activePresetProvider.notifier).state = 'custom';
- ref.read(showTitleProvider.notifier).state = value;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setBool('show_title', value);
- await prefs.setString('active_preset', 'custom');
- },
- ),
- SwitchListTile(
- title: const Text('Show buttons on hover only'),
- subtitle: const Text('Buttons appear when hovering over a card'),
- value: showButtonsOnHover,
- contentPadding: EdgeInsets.zero,
- onChanged: (value) async {
- ref.read(activePresetProvider.notifier).state = 'custom';
- ref.read(showButtonsOnHoverProvider.notifier).state = value;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setBool('show_buttons_on_hover', value);
- await prefs.setString('active_preset', 'custom');
- },
- ),
- ],
- );
- }
-
- Widget _presetChip(String label, String presetKey, String activePreset) {
- final isSelected = activePreset == presetKey;
- return FilterChip(
- label: Text(label),
- selected: isSelected,
- onSelected: (selected) async {
- if (!selected) return;
- ref.read(activePresetProvider.notifier).state = presetKey;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setString('active_preset', presetKey);
- if (presetKey == 'custom') return;
- final preset = kDisplayPresets[presetKey];
- if (preset == null) return;
- final cols = preset['columnCount'] as int;
- final ratio = preset['cardAspectRatio'] as double;
- final spacing = preset['cardSpacing'] as double;
- final title = preset['showTitle'] as bool;
- final hover = preset['showButtonsOnHover'] as bool;
- ref.read(columnCountProvider.notifier).state = cols;
- ref.read(cardAspectRatioProvider.notifier).state = ratio;
- ref.read(cardSpacingProvider.notifier).state = spacing;
- ref.read(showTitleProvider.notifier).state = title;
- ref.read(showButtonsOnHoverProvider.notifier).state = hover;
- await prefs.setInt('column_count', cols);
- await prefs.setDouble('card_aspect_ratio', ratio);
- await prefs.setDouble('card_spacing', spacing);
- await prefs.setBool('show_title', title);
- await prefs.setBool('show_buttons_on_hover', hover);
- },
- );
- }
-
- Widget _buildRetroArchSyncModeSection(BuildContext context, WidgetRef ref) {
- final syncMode = ref.watch(retroarchSyncModeProvider);
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Text('RetroArch Save Sync', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
- const SizedBox(height: 8),
- const Text('What to sync with RomM cloud', style: TextStyle(color: Colors.grey)),
- const SizedBox(height: 12),
- SegmentedButton<String>(
- segments: const [
- ButtonSegment(value: 'saves', label: Text('Saves only')),
- ButtonSegment(value: 'states', label: Text('States only')),
- ButtonSegment(value: 'both', label: Text('Both')),
- ],
- selected: {syncMode},
- onSelectionChanged: (selection) async {
- final value = selection.first;
- ref.read(retroarchSyncModeProvider.notifier).state = value;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setString('retroarch_sync_mode', value);
- },
- ),
- ],
- );
- }
-
+ // --- RomM Server Section ---
Widget _buildRommServerSection(BuildContext context, WidgetRef ref, rommService) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -358,6 +215,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
return;
}
try {
+ // Test connection by fetching platforms
final platforms = await rommService.getPlatforms();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -400,9 +258,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
await prefs.setString('rommUsername', username);
await prefs.setString('rommPassword', password);
- // ignore: unused_result
+ // Invalidate providers to refresh RomM service and config
ref.invalidate(rommConfigProvider);
- // ignore: unused_result
ref.invalidate(rommServiceProvider);
if (context.mounted) {
@@ -422,6 +279,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
+ // --- Storage Section ---
Widget _buildStorageSection(DirectoryService directoryService) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -434,8 +292,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
onChanged: (newPath) async {
if (newPath != null) {
await directoryService.setRomsRoot(newPath);
- // ignore: unused_result
- ref.refresh(directoryServiceProvider);
+ // Fix: Replace ref.refresh with ref.invalidate
+ ref.invalidate(directoryServiceProvider);
}
},
),
@@ -446,8 +304,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
onChanged: (newPath) async {
if (newPath != null) {
await directoryService.setEmulatorsRoot(newPath);
- // ignore: unused_result
- ref.refresh(directoryServiceProvider);
+ // Fix: Replace ref.refresh with ref.invalidate
+ ref.invalidate(directoryServiceProvider);
}
},
),
@@ -484,82 +342,30 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
- Widget _buildEmulatorsSection(DirectoryService directoryService) {
+ // --- RetroArch Sync Mode Section ---
+ Widget _buildRetroArchSyncModeSection(BuildContext context, WidgetRef ref) {
+ final syncMode = ref.watch(retroarchSyncModeProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- const Text('Emulators',
- style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
- const SizedBox(height: 16),
- if (!_emulatorsLoaded)
- const Center(child: CircularProgressIndicator())
- else
- ...kEmulatorDefinitions.map<Widget>((def) {
- final emulatorId = def['id'] as String;
- final emulatorName = def['name'] as String;
- final isInstalled = _emulatorInstallStates[emulatorId] ?? false;
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 8.0),
- child: Row(
- children: [
- Icon(
- isInstalled ? Icons.check_circle : Icons.cancel,
- color: isInstalled ? Colors.green : Colors.red,
- ),
- const SizedBox(width: 8),
- Expanded(child: Text(emulatorName)),
- ElevatedButton(
- onPressed: isInstalled
- ? null
- : () async {
- ScaffoldMessenger.of(context).showSnackBar(SnackBar(
- content:
- Text('Starting download for $emulatorName...'),
- ));
- final emulatorDownloadService =
- EmulatorDownloadService(
- Dio(), directoryService);
- try {
- await for (final progress in emulatorDownloadService
- .downloadEmulator(emulatorId)) {
- if (progress.error != null) {
- if (mounted) {
- ScaffoldMessenger.of(context)
- .showSnackBar(SnackBar(
- content: Text('Error: ${progress.error}'),
- ));
- }
- break;
- }
- if (progress.isComplete) {
- if (mounted) {
- setState(() {
- _emulatorInstallStates[emulatorId] = true;
- });
- ScaffoldMessenger.of(context)
- .showSnackBar(SnackBar(
- content:
- Text('$emulatorName downloaded.'),
- ));
- }
- break;
- }
- }
- } catch (e) {
- if (mounted) {
- ScaffoldMessenger.of(context)
- .showSnackBar(SnackBar(
- content: Text('Unexpected error: $e'),
- ));
- }
- }
- },
- child: Text(isInstalled ? 'Installed' : 'Download'),
- ),
- ],
- ),
- );
- }),
+ const Text('RetroArch Save Sync', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 8),
+ const Text('What to sync with RomM cloud', style: TextStyle(color: Colors.grey)),
+ const SizedBox(height: 12),
+ SegmentedButton<String>(
+ segments: const [
+ ButtonSegment(value: 'saves', label: Text('Saves only')),
+ ButtonSegment(value: 'states', label: Text('States only')),
+ ButtonSegment(value: 'both', label: Text('Both')),
+ ],
+ selected: {syncMode},
+ onSelectionChanged: (selection) async {
+ final value = selection.first;
+ ref.read(retroarchSyncModeProvider.notifier).state = value;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString('retroarch_sync_mode', value);
+ },
+ ),
],
);
}
diff --git a/lib/ui/widgets/game_card.dart b/lib/ui/widgets/game_card.dart
index e41b224..cda7d41 100644
--- a/lib/ui/widgets/game_card.dart
+++ b/lib/ui/widgets/game_card.dart
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:cached_network_image/cached_network_image.dart';
import '../../core/romm/romm_models.dart';
-import '../../providers/romm_provider.dart';
-class GameCard extends ConsumerStatefulWidget {
+class GameCard extends StatefulWidget {
final Game game;
+ final String? coverUrl;
final VoidCallback onDownload;
final VoidCallback onLaunch;
final VoidCallback? onSyncSaves;
@@ -15,6 +15,7 @@ class GameCard extends ConsumerStatefulWidget {
const GameCard({
super.key,
required this.game,
+ this.coverUrl,
required this.onDownload,
required this.onLaunch,
this.onSyncSaves,
@@ -24,23 +25,26 @@ class GameCard extends ConsumerStatefulWidget {
});
@override
- ConsumerState<GameCard> createState() => _GameCardState();
+ State<GameCard> createState() => _GameCardState();
}
-class _GameCardState extends ConsumerState<GameCard> {
- bool _isHovering = false;
+class _GameCardState extends State<GameCard> {
+ final ValueNotifier<bool> _hovering = ValueNotifier(false);
@override
- Widget build(BuildContext context) {
- final service = ref.watch(rommServiceProvider);
- final finalCoverUrl = service?.resolveCoverUrl(widget.game);
+ void dispose() {
+ _hovering.dispose();
+ super.dispose();
+ }
+ @override
+ Widget build(BuildContext context) {
return RepaintBoundary(
child: GestureDetector(
onLongPress: () => _showContextMenu(context),
child: MouseRegion(
- onEnter: (_) => setState(() => _isHovering = true),
- onExit: (_) => setState(() => _isHovering = false),
+ onEnter: (_) => _hovering.value = true,
+ onExit: (_) => _hovering.value = false,
child: Card(
elevation: 2,
clipBehavior: Clip.antiAlias,
@@ -53,13 +57,21 @@ class _GameCardState extends ConsumerState<GameCard> {
child: Stack(
fit: StackFit.expand,
children: [
- (finalCoverUrl == null || finalCoverUrl.isEmpty)
+ (widget.coverUrl == null || widget.coverUrl!.isEmpty)
? const Center(child: Icon(Icons.sports_esports, size: 48))
- : Image.network(
- finalCoverUrl,
+ : CachedNetworkImage(
+ imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
- errorBuilder: (context, error, stackTrace) => const Center(
+ memCacheWidth: 300,
+ memCacheHeight: 400,
+ placeholder: (context, url) => Container(
+ color: Colors.grey[300],
+ child: const Center(
+ child: Icon(Icons.image, color: Colors.grey),
+ ),
+ ),
+ errorWidget: (context, url, error) => const Center(
child: Icon(Icons.sports_esports, size: 48),
),
),
@@ -81,74 +93,79 @@ class _GameCardState extends ConsumerState<GameCard> {
),
),
// Content - fixed height
- SizedBox(
- height: 90,
- child: SingleChildScrollView(
- physics: const NeverScrollableScrollPhysics(),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- if (!widget.showButtonsOnHover || !_isHovering)
- if (widget.showTitle)
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 1.0),
- child: Text(
- widget.game.displayName,
- maxLines: 2,
- textAlign: TextAlign.center,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
- ),
- )
- else
- const Padding(
- padding: EdgeInsets.symmetric(vertical: 1.0),
- child: Center(
- child: Icon(Icons.more_horiz, size: 16, color: Colors.grey),
- ),
- ),
-
- if (!widget.showButtonsOnHover || _isHovering)
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- IconButton(
- visualDensity: VisualDensity.compact,
- iconSize: 22,
- padding: EdgeInsets.zero,
- constraints: const BoxConstraints(),
- icon: const Icon(Icons.download),
- onPressed: widget.onDownload,
- tooltip: 'Download',
+ ValueListenableBuilder<bool>(
+ valueListenable: _hovering,
+ builder: (context, isHovering, child) {
+ return SizedBox(
+ height: 90,
+ child: SingleChildScrollView(
+ physics: const NeverScrollableScrollPhysics(),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (!widget.showButtonsOnHover || !isHovering)
+ if (widget.showTitle)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 1.0),
+ child: Text(
+ widget.game.displayName,
+ maxLines: 2,
+ textAlign: TextAlign.center,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
+ ),
+ )
+ else
+ const Padding(
+ padding: EdgeInsets.symmetric(vertical: 1.0),
+ child: Center(
+ child: Icon(Icons.more_horiz, size: 16, color: Colors.grey),
+ ),
),
- IconButton(
- visualDensity: VisualDensity.compact,
- iconSize: 22,
- padding: EdgeInsets.zero,
- constraints: const BoxConstraints(),
- icon: const Icon(Icons.play_arrow),
- onPressed: widget.onLaunch,
- tooltip: 'Launch',
+
+ if (!widget.showButtonsOnHover || isHovering)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ IconButton(
+ visualDensity: VisualDensity.compact,
+ iconSize: 22,
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(),
+ icon: const Icon(Icons.download),
+ onPressed: widget.onDownload,
+ tooltip: 'Download',
+ ),
+ IconButton(
+ visualDensity: VisualDensity.compact,
+ iconSize: 22,
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(),
+ icon: const Icon(Icons.play_arrow),
+ onPressed: widget.onLaunch,
+ tooltip: 'Launch',
+ ),
+ if (widget.onSyncSaves != null)
+ IconButton(
+ visualDensity: VisualDensity.compact,
+ iconSize: 18,
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(),
+ icon: const Icon(Icons.cloud_upload),
+ onPressed: widget.onSyncSaves,
+ tooltip: 'Sync saves',
+ ),
+ ],
),
- if (widget.onSyncSaves != null)
- IconButton(
- visualDensity: VisualDensity.compact,
- iconSize: 18,
- padding: EdgeInsets.zero,
- constraints: const BoxConstraints(),
- icon: const Icon(Icons.cloud_upload),
- onPressed: widget.onSyncSaves,
- tooltip: 'Sync saves',
- ),
- ],
- ),
- ),
- ],
- ),
- ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
),
],
),
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 55e04aa..42545f9 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,8 +7,10 @@ import Foundation
import package_info_plus
import shared_preferences_foundation
+import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+ SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}
diff --git a/pubspec.lock b/pubspec.lock
index 5018c6d..7a95e6d 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -25,6 +25,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
+ cached_network_image:
+ dependency: "direct main"
+ description:
+ name: cached_network_image
+ sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.0"
+ cached_network_image_platform_interface:
+ dependency: transitive
+ description:
+ name: cached_network_image_platform_interface
+ sha256: ff0c949e323d2a1b52be73acce5b4a7b04063e61414c8ca542dbba47281630a7
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.0"
+ cached_network_image_web:
+ dependency: transitive
+ description:
+ name: cached_network_image_web
+ sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
characters:
dependency: transitive
description:
@@ -129,11 +153,27 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.0.0+1"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
+ flutter_cache_manager:
+ dependency: transitive
+ description:
+ name: flutter_cache_manager
+ sha256: a77f77806a790eb9ba0118a5a3a936e81c4fea2b61533033b2b0c3d50bbde5ea
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -288,6 +328,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.3.0"
+ octo_image:
+ dependency: transitive
+ description:
+ name: octo_image
+ sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
package_info_plus:
dependency: "direct main"
description:
@@ -400,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
+ rxdart:
+ dependency: transitive
+ description:
+ name: rxdart
+ sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@@ -469,6 +525,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
+ sqflite:
+ dependency: transitive
+ description:
+ name: sqflite
+ sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ sqflite_android:
+ dependency: transitive
+ description:
+ name: sqflite_android
+ sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2+3"
+ sqflite_common:
+ dependency: transitive
+ description:
+ name: sqflite_common
+ sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.6"
+ sqflite_darwin:
+ dependency: transitive
+ description:
+ name: sqflite_darwin
+ sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ sqflite_platform_interface:
+ dependency: transitive
+ description:
+ name: sqflite_platform_interface
+ sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -501,6 +597,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ synchronized:
+ dependency: transitive
+ description:
+ name: synchronized
+ sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -525,6 +629,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ uuid:
+ dependency: transitive
+ description:
+ name: uuid
+ sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.5.3"
vector_math:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index ebbfe54..8bc4258 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -35,6 +35,7 @@ dependencies:
path_provider: ^2.1.2
shared_preferences: ^2.2.2
package_info_plus: ^5.0.1
+ cached_network_image: ^3.3.1
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.