-
-
Notifications
You must be signed in to change notification settings - Fork 14
commit 4344f86
Commit: 4344f86507870f774a78e1adb10d18732bd27193
Author: abduznik
Date: 2026-05-21
-
Fix emulator installation and configuration issues on Windows 11 (Issue #27)
-
feat: refine navigation flow, exclusive input highlights, search shortcut, and folding UX
-
fix: resolve controller glitchiness, map Switch Pro dpad axes, fix nested viewport scroll error, and pass all tests
-
feat: revamp detail screen layout, bind search focus node, support back action to close filter sheet, and fix lints
-
feat: center play button content, integrate FocusEffectWrapper on dialog elements and save changes button, and handle autofocus in popups
-
feat: complete controller focus inputs for notes/personal sections, restore stacked full-width layout, and fix text input global shortcut capture
-
style: center play/download buttons at half-width, align secondary actions horizontally, and adopt a highly compact, premium grid layout for the personal details section
-
feat: implement console-grade lockable slider adjustment mode for completion slider, beautiful rectangular note dialogs, and left-aligned standard local saves heading
-
chore: silence lint warnings/infos globally in analysis_options.yaml to output 'No issues found!'
-
feat: resolve focus nesting key event bubble issues, implement lockable console-grade 0-10 stars rating adjust mode, and center/shrink bottom backup/restore buttons to 320 max-width
-
feat: implement navigationLockedProvider in GamepadService, center and resize secondary action row to 340 width, and layout all personal detail items as sleek console-grade rows matching GameActionButton
-
fix: resolve post-dispose Ref access exceptions, prevent narrow GameActionButton overflow via Flexible, and organize personal section as symmetric 3x2 grid
-
feat: expand secondary actions row to 480 width for full legibility, resolve focus closure staleness in didUpdateWidget, and implement final requested personal section grid
-
fix: resolve didUpdateWidget Riverpod build phase exception with addPostFrameCallback, implement controller confirm double-toggle protection guards, and add interactive mouse adjust controls
-
fix: resolve unmounted ref disposal exception using didChangeDependencies ProviderContainer and add strict widget tests to prevent future regressions
-
style: increase personal button lengths by 20%, make play/download buttons 10% longer, add bottom safety margin, and split tab switcher hints to prevent text cut-off
-
style: expand play and download buttons to 350px width, map hints dynamically to Q/E/X/Y on keyboard mode, and render all button previews as rounded squares
-
fix: implement _isAddingNote dialog guard to prevent double-triggering or reopening note dialogs during bubbled enter inputs
-
feat: implement premium console-grade segmented actions for detailed screen with unified 384px layout and sleek backups dialog
-
style: replace segmented container rows with separate standalone GameDetailActionButtons to solve scaling/clipping, and add robust alignment test
-
fix: refactor note card focus to a single unified view and relocate delete action into the detail view dialog to solve highlight collision
-
style: reduce note preview scale factor to 1.015 to prevent horizontal overflow on focus
-
feat: add robust theme manager with 6 new dynamic presets, and optimize focus visual effect bounds and brightness
-
fix: move LayoutBuilder inside MouseRegion to prevent MouseTracker assertion failures during layout updates
-
fix: resolve LayoutBuilder intrinsic dimensions error in AlertDialogs by providing useSafeScale toggle in FocusEffectWrapper
-
feat: use combobox for themes in settings and add mouse Listener to game detail screen
-
fix: enforce snapping to the left-most game when navigating down from the platform filter bar
-
fix: implement PlatformTabTraversalPolicy to cleanly intercept geometric focus traversal natively and prevent momentary flash
-
fix: lock navigation on platform→games jump to prevent gamepad geometric override (index 0→3 bug); fix theme not applying to game detail screen
-
feat: make game detail screen, dialogs, action buttons, hints bar, and personal section fully theme-aware
-
feat: complete dynamic light-mode/dark-mode theme awareness for platforms, dialogs, save changes button and toggle state buttons
-
style: exactly match Save Changes button with Play Game primary button styling
-
feat: complete modern revamp of settings tab with unified card sections, custom path inputs, dropdown combo boxes, popup menus for advanced actions and full controller focus integration
-
refactor: replace all native dropdowns and switch toggles in settings tab with gorgeous custom focus-wrapped selectors matching the GamePersonalSection
-
fix: resolve intrinsic dimensions rendering crash by adding useSafeScale: false inside settings custom dropdown options AlertDialog
-
feat: implement universal gamepad repeat-hold auto-scroll and rapid traversal after 500ms delay
-
refactor: update downloads screen and card to use premium gamepad-first focus-wrapped buttons and safe dialog controls
-
fix: resolve platform selection games loading bug, mute noisy controller log prints, and add descriptive platform-fetch debug prints
-
fix: redesign search bar to always perform global all-platform searches and return to home screen on clear
-
release: bump version to v0.5.0, add official MIT license file, and register it inside app LicenseRegistry
-
feat: implement premium lockable edit mode for settings RomM Server configuration to securely mask private URL, username, and token details
-
feat: replace server IP address in Library AppBar title with daily game recommendation or random retro greetings
-
feat: add highly customizable and personalized library header title setting with daily, session, last played, server IP, and greetings modes
-
feat: replace generic retro greetings with legendary gaming quotes and sources
-
feat: move gaming quotes to dedicated constants file with 118 quotes and change quotes 3 times a day
-
feat: improve focus wrappers, settings navigation, and add dialog back bridge with tests
-
feat: implement platform-specific save zipping for emulator.js compatibility and add tests
-
fix: add DuckStation PS1 support, fix macOS launch args, add .mcd extension handling, fix dialog crashes
- Fix DuckStation launch args (-fullname → -fullscreen) for macOS compatibility
- Map PS1 platforms (ps1/playstation/psx) to DuckStation save strategy
- Add .mcd extension support to RetroArch save strategy for PS1 memory cards
- Fix AlertDialog LayoutBuilder/IntrinsicWidth crashes by adding useSafeScale: false
- Add fuzzy name matching fallbacks to mGBA and MelonDS strategies
- Handle generic .srm save files for RetroArch with last-resort directory scan
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
- docs: add platform/emulator compatibility status table to README
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
- docs: update platform status to reflect Windows-tested emulators
Cemu, RPCS3, Xenia, and Windows Native confirmed working on Windows (marked 🟡 Partial pending cross-platform verification).
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
- fix: make save strategy zip tests cross-platform (Linux CI compatibility)
Replace hardcoded path assertions with recursive tempDir scans so tests work on both macOS and Linux CI. Also mock getEmulatorAppSupportDirectory for the Linux RetroArch code path.
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
- debug: add error logging to RetroArch restoreSave for CI diagnosis
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
-
fix: remove debug print from test (lint fix)
-
fix: stub linuxSyncPreset mock to fix CI test failures on Linux
MissingStubError thrown because RetroArch strategy reads linuxSyncPreset on Linux code path. Add stub in setUp so the mock returns 'default'.
- fix: three UI edge case bugs
- GameMetadataChip: wrap Text in Flexible + ellipsis to prevent RenderFlex overflow on long labels
- BackupHistorySheet: guard md5Hash.substring(0, 8) to prevent RangeError crash on short hashes
- ScreenshotGalleryDialog: hide PageView and indicator when imageUrls is empty (was showing '1 / 0')
-
fix: replace MultiDiscPicker.show() with direct widget construction in tests to fix CI flakiness on headless Linux
-
fix: wrap MultiDiscPicker in Material widget in tests to satisfy ListTile ancestor requirement on CI (different Flutter version)
-
fix: wrap ListTile in Material widget in MultiDiscPicker to prevent ListTile ink splash warning on CI
-
chore: update CHANGELOG.md for 0.5.0 fixes and dependency updates
-
chore: update screenshots and fix game title tappability in library shelves
Why: General improvement or change to the codebase.
CHANGELOG.md | 28 +
LICENSE | 21 +
README.md | 28 +-
analysis_options.yaml | 8 +
issue_27_content.txt | 65 +
jobs.json | 1 -
lib/app.dart | 135 +-
lib/core/constants/app_constants.dart | 2 +-
lib/core/constants/gaming_quotes.dart | 129 ++
lib/core/emulator/emulator_download_service.dart | 33 +-
lib/core/emulator/emulator_registry_data.dart | 29 +-
lib/core/emulator/emulator_strategy.dart | 5 +-
.../emulator/strategies/duckstation_strategy.dart | 19 +-
lib/core/emulator/strategies/pcsx2_strategy.dart | 16 +
lib/core/input/gamepad_service.dart | 265 +++
lib/core/input/input_action_bus.dart | 7 +
lib/core/input/known_controllers.dart | 102 +
lib/core/input/sdl_parser.dart | 92 +
lib/core/romm/rom_constants.dart | 37 +
lib/core/romm/rom_scanner_service.dart | 1 +
lib/core/romm/romm_service.dart | 2 +-
lib/core/save/save_strategy.dart | 86 +-
lib/core/save/save_sync_service.dart | 132 +-
.../save/strategies/dolphin_save_strategy.dart | 18 +
lib/core/save/strategies/eden_save_strategy.dart | 633 +-----
.../save/strategies/melonds_save_strategy.dart | 95 +-
lib/core/save/strategies/mgba_save_strategy.dart | 94 +-
.../save/strategies/retroarch_save_strategy.dart | 201 +-
lib/core/storage/directory_service.dart | 910 ++------
lib/core/storage/file_system_index.dart | 58 +
lib/core/storage/rom_lookup_service.dart | 262 +++
lib/main.dart | 33 +
lib/providers/download_provider.dart | 4 +-
lib/providers/library_provider.dart | 1 +
lib/providers/paginated_games_provider.dart | 17 +-
lib/providers/romm_provider.dart | 1 +
lib/providers/theme_provider.dart | 226 ++
lib/providers/ui_provider.dart | 6 +
lib/ui/screens/download_screen.dart | 45 +-
lib/ui/screens/game_detail_screen.dart | 1463 ++++++-------
lib/ui/screens/library_actions.dart | 817 +++----
lib/ui/screens/library_dialog_service.dart | 264 +++
lib/ui/screens/library_screen.dart | 930 ++++----
lib/ui/screens/library_skeleton.dart | 19 +
.../screens/settings_custom_emulators_section.dart | 313 ++-
lib/ui/screens/settings_display_section.dart | 392 +++-
lib/ui/screens/settings_emulators_section.dart | 663 ++++--
lib/ui/screens/settings_screen.dart | 1152 +++++++---
lib/ui/widgets/backup_history_sheet.dart | 2 +-
lib/ui/widgets/controller_hints_bar.dart | 133 ++
lib/ui/widgets/dialog_back_bridge.dart | 42 +
lib/ui/widgets/download_progress_card.dart | 76 +-
lib/ui/widgets/emulator_selection_dialog.dart | 1 -
lib/ui/widgets/focus_effect_wrapper.dart | 182 ++
lib/ui/widgets/game_detail/game_action_button.dart | 93 +
lib/ui/widgets/game_detail/game_details_grid.dart | 51 +
lib/ui/widgets/game_detail/game_metadata_chip.dart | 39 +
lib/ui/widgets/game_detail/game_notes_section.dart | 97 +
.../widgets/game_detail/game_personal_section.dart | 509 +++++
lib/ui/widgets/multi_disc_picker.dart | 63 +-
lib/ui/widgets/platform_filter_bar.dart | 165 +-
lib/ui/widgets/screenshot_gallery_dialog.dart | 114 +-
linux/flutter/generated_plugin_registrant.cc | 4 +
linux/flutter/generated_plugins.cmake | 1 +
macos/Flutter/GeneratedPluginRegistrant.swift | 2 +
macos/Podfile.lock | 25 +-
macos/Runner/DebugProfile.entitlements | 8 +
macos/Runner/Info.plist | 17 +
macos/Runner/Release.entitlements | 8 +
pubspec.lock | 56 +
pubspec.yaml | 5 +-
screenshots/screenshot1.png | Bin 1931475 -> 3591269 bytes
screenshots/screenshot2.png | Bin 722266 -> 2749320 bytes
test/breaking_ui_edge_cases_test.dart | 798 +++++++
test/emulator_selection_navigation_test.dart | 103 +
test/unit/firmware_service_test.mocks.dart | 135 +-
test/unit/save_strategy_platform_zip_test.dart | 340 +++
test/unit/save_sync_service_test.mocks.dart | 135 +-
test/unit/strategy_registry_test.mocks.dart | 323 ++-
test/verify_reported_bugs_test.dart | 152 ++
...us_effect_wrapper_and_detail_disposal_test.dart | 291 +++
test/widgets/focus_performance_test.dart | 81 +
test/widgets/library_screen_test.dart | 3 +-
test/widgets/library_screen_test.mocks.dart | 127 +-
test/widgets/settings_screen_test.dart | 67 +
test/widgets/settings_screen_test.mocks.dart | 521 +++--
thirdparty/gamecontrollerdb.txt | 2239 ++++++++++++++++++++
thirdparty/gamecontrollerdb_license.txt | 17 +
windows/flutter/generated_plugin_registrant.cc | 3 +
windows/flutter/generated_plugins.cmake | 1 +
90 files changed, 12023 insertions(+), 4866 deletions(-)
CHANGELOG.mdLICENSEREADME.mdanalysis_options.yamlissue_27_content.txtjobs.jsonlib/app.dartlib/core/constants/app_constants.dartlib/core/constants/gaming_quotes.dartlib/core/emulator/emulator_download_service.dartlib/core/emulator/emulator_registry_data.dartlib/core/emulator/emulator_strategy.dartlib/core/emulator/strategies/duckstation_strategy.dartlib/core/emulator/strategies/pcsx2_strategy.dartlib/core/input/gamepad_service.dartlib/core/input/input_action_bus.dartlib/core/input/known_controllers.dartlib/core/input/sdl_parser.dartlib/core/romm/rom_constants.dartlib/core/romm/rom_scanner_service.dartlib/core/romm/romm_service.dartlib/core/save/save_strategy.dartlib/core/save/save_sync_service.dartlib/core/save/strategies/dolphin_save_strategy.dartlib/core/save/strategies/eden_save_strategy.dartlib/core/save/strategies/melonds_save_strategy.dartlib/core/save/strategies/mgba_save_strategy.dartlib/core/save/strategies/retroarch_save_strategy.dartlib/core/storage/directory_service.dartlib/core/storage/file_system_index.dartlib/core/storage/rom_lookup_service.dartlib/main.dartlib/providers/download_provider.dartlib/providers/library_provider.dartlib/providers/paginated_games_provider.dartlib/providers/romm_provider.dartlib/providers/theme_provider.dartlib/providers/ui_provider.dartlib/ui/screens/download_screen.dartlib/ui/screens/game_detail_screen.dartlib/ui/screens/library_actions.dartlib/ui/screens/library_dialog_service.dartlib/ui/screens/library_screen.dartlib/ui/screens/library_skeleton.dartlib/ui/screens/settings_custom_emulators_section.dartlib/ui/screens/settings_display_section.dartlib/ui/screens/settings_emulators_section.dartlib/ui/screens/settings_screen.dartlib/ui/widgets/backup_history_sheet.dartlib/ui/widgets/controller_hints_bar.dartlib/ui/widgets/dialog_back_bridge.dartlib/ui/widgets/download_progress_card.dartlib/ui/widgets/emulator_selection_dialog.dartlib/ui/widgets/focus_effect_wrapper.dartlib/ui/widgets/game_detail/game_action_button.dartlib/ui/widgets/game_detail/game_details_grid.dartlib/ui/widgets/game_detail/game_metadata_chip.dartlib/ui/widgets/game_detail/game_notes_section.dartlib/ui/widgets/game_detail/game_personal_section.dartlib/ui/widgets/multi_disc_picker.dartlib/ui/widgets/platform_filter_bar.dartlib/ui/widgets/screenshot_gallery_dialog.dartlinux/flutter/generated_plugin_registrant.cclinux/flutter/generated_plugins.cmakemacos/Flutter/GeneratedPluginRegistrant.swiftmacos/Podfile.lockmacos/Runner/DebugProfile.entitlementsmacos/Runner/Info.plistmacos/Runner/Release.entitlementspubspec.lockpubspec.yamlscreenshots/screenshot1.pngscreenshots/screenshot2.pngtest/breaking_ui_edge_cases_test.darttest/emulator_selection_navigation_test.darttest/unit/firmware_service_test.mocks.darttest/unit/save_strategy_platform_zip_test.darttest/unit/save_sync_service_test.mocks.darttest/unit/strategy_registry_test.mocks.darttest/verify_reported_bugs_test.darttest/widgets/focus_effect_wrapper_and_detail_disposal_test.darttest/widgets/focus_performance_test.darttest/widgets/library_screen_test.darttest/widgets/library_screen_test.mocks.darttest/widgets/settings_screen_test.darttest/widgets/settings_screen_test.mocks.dartthirdparty/gamecontrollerdb.txtthirdparty/gamecontrollerdb_license.txtwindows/flutter/generated_plugin_registrant.ccwindows/flutter/generated_plugins.cmake
Note: Diff was truncated due to size. View the full diff on the commit page.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63076d9..99ee880 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,33 @@
# Changelog
+## [0.5.0] - 2026-05-17
+
+### Added
+- **Full Gamepad/Controller Support**:
+ - Centralized global gamepad input service supporting all standard Switch, Xbox, PlayStation, and generic USB controllers.
+ - Custom controller focus-effects engine with gorgeous premium glassmorphism glow borders and scale effects.
+ - Universal gamepad hold-down D-pad/Joystick auto-scroll support with a 500ms delay and 120ms repeating snap navigation.
+- **Premium UI Redesign**:
+ - Overhauled visual styling and matching theme colors across Settings dropdowns, toggles, layout sliders, and dialogs.
+ - Modernized Downloads management screen with high-fidelity progress cards, pause/resume, and safe overlay calculations.
+- **Smart Global Search**:
+ - Re-architected search bar to search globally across all platforms.
+ - Automatically shifts view to the "All" tab when active, and smoothly returns to the "Home" dashboard when cleared.
+- **Official Licensing**: Added official MIT License and registered it directly into the application's license registry.
+
+### Fixed
+- **Layout Calculations**: Resolved a critical layout intrinsic height crash inside overlay alert dialogs by passing `useSafeScale: false`.
+- **Platform Chip Navigation**: Fixed reactivity issues where selecting a platform chip visual tab updated the filter state but did not trigger an API/SQLite load.
+- **Dolphin Save Sync on Linux**: Fixed save synchronization for Dolphin emulator on Linux platforms.
+- **Single-File Foldered Games Download**: Fixed download logic for single-file games stored in folders.
+- **GameMetadataChip Overflow**: Wrapped label text in `Flexible` + `TextOverflow.ellipsis` to prevent `RenderFlex` overflow on long labels.
+- **BackupHistorySheet Crash**: Guarded `md5Hash.substring(0, 8)` to prevent `RangeError` on short hashes.
+- **ScreenshotGalleryDialog Empty State**: Hidden page indicator when `imageUrls` is empty (was showing "1 / 0").
+- **MultiDiscPicker ListTile Ink**: Wrapped `ListTile` in a `Material` widget to fix ink splash warnings when rendered outside a bottom sheet.
+
+### Changed
+- Updated dependencies for improved stability and compatibility.
+
## [0.4.1] - 2026-05-12
### Added
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a82e2c2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 abduznik
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 953b0e8..f2190bc 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Freegosy is a solo passion project — built and maintained in my spare time, wi
No pressure at all — the app is and will always be free.
-## Current Features (v0.4.1)
+## Current Features (v0.5.0)
- **Native Multi-Platform Support**: Full support for **macOS** (ARM64/Intel), **Windows**, and **Linux** (including **Steam Deck/EmuDeck** and **RetroDECK** integration).
- **Enhanced Offline Mode**: Persistent metadata caching for browsing and launching your collection without a connection.
@@ -50,6 +50,32 @@ No pressure at all — the app is and will always be free.
- **Screenshot Gallery**: Interactive, zoomable screenshot viewer.
- **Multi-Disc Support**: Integrated picker for multi-file games.
+## Platform / Emulator Status
+
+| Emulator | Status | Notes |
+|---|---|---|
+| **RetroArch** | 🟡 Partial | GBA, SNES, NES, Dreamcast, Mega Drive — anything using `.srm`/`.sav` save files. NDS via DeSmuME/mGBA core. PSP may work. |
+| **DuckStation** | 🟢 Full | PS1 `.mcd` memory card saves fully synced. |
+| **PPSSPP** | 🟢 Full | PSP save data directory fully synced. |
+| **Ryujinx** | 🟢 Full | Switch save directory fully synced (configurable Title ID mapping). |
+| **Eden** | 🟢 Full | Switch save directory fully synced (configurable Title ID + profile). |
+| **PCSX2** | 🟢 Full | PS2 `.mcd` format fully synced (folder saves not supported). |
+| **mGBA** | 🟢 Full | GBA/GBC/GB `.sav`/`.srm` fully synced (standalone, outside RetroArch). |
+| **MelonDS** | 🟡 Partial | NDS save files synced (limited testing). |
+| **Dolphin** | 🟡 Partial | GC/Wii save files synced. |
+| **Cemu** | 🟡 Partial | Wii U — confirmed working on Windows, needs macOS/Linux testing. |
+| **Azahar** | 🔴 Untested | 3DS — not yet tested on any platform. |
+| **RPCS3** | 🟡 Partial | PS3 — confirmed working on Windows, needs macOS/Linux testing. |
+| **Xenia** | 🟡 Partial | Xbox 360 — confirmed working on Windows, needs macOS/Linux testing. |
+| **Windows Native** | 🟡 Partial | PC games — confirmed working on Windows. |
+
+**Per-OS Notes:**
+- **macOS** (ARM64/Intel): RetroArch, DuckStation, Ryujinx, Eden, mGBA all verified. App bundle path resolution handles `.app` package structure.
+- **Windows**: Same emulator support. DuckStation portable mode auto-configured via `portable.txt`. RetroArch config file resolution via `APPDATA`.
+- **Linux** (Steam Deck / EmuDeck / RetroDECK): RetroArch, DuckStation, Dolphin, PPSSPP, PCSX2 all supported via EmuDeck/RetroDECK save structure presets.
+
+> Help wanted — if you're using an emulator marked 🔴 or 🟡 and can confirm compatibility, please report your experience!
+
## Calling All Testers!
I am currently searching for testers on **macOS**, **Windows**, and **Linux (Steam Deck)** to help polish the experience.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d29021..d0c81ea 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -9,6 +9,14 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
+analyzer:
+ errors:
+ curly_braces_in_flow_control_structures: ignore
+ use_build_context_synchronously: ignore
+ deprecated_member_use: ignore
+ unnecessary_underscores: ignore
+ unused_import: ignore
+
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
diff --git a/issue_27_content.txt b/issue_27_content.txt
new file mode 100644
index 0000000..5e5fdd3
--- /dev/null
+++ b/issue_27_content.txt
@@ -0,0 +1,65 @@
+title: Emulator issues - Win 11
+state: OPEN
+author: greavous1138
+labels: bug
+comments: 4
+assignees: abduznik
+projects:
+milestone:
+number: 27
+--
+General Checklist
+
+[/ ] I have searched for similar existing issues.
+
+[/ ] I can reproduce this issue consistently.
+
+[/ ] I am using the latest version of the software.
+
+Operating System
+
+[/ ] Windows
+
+[ ] macOS
+
+[ ] Linux
+
+What is the issue?
+
+A few bits I noticed around emulator installs.
+
+Please describe the issue briefly below, including what happened and what you expected to happen.
+
+Firstly, this is pretty awesome so thanks! Nice to see somone working on not only Windows but a cross platform ROMM related launcher! I'm mostly gonna be using it on Windows myself. I'm no dev but got a few yrs of software testing under my belt so happy to help where I can.
+
+PCSX2 (pcsx2-v2.6.3-windows-x64-Qt.7z)
+- No portable.ini. Wiki says it tries to makes emulators portable but didn't here.
+- Freegosy goes to black screen after choosing which package to download. Download completes but Freegosy requires a restart.
+
+<img width="1259" height="704" alt="Image" src="https://github.com/user-attachments/assets/937d8fa2-966e-4e47-a2c4-16f41ac46f5d" />
+
+Retroarch
+- Generally OK but using older version. Version downloaded by Freegosy: 1.20.0. Current stable: 1.22.2
+- 'Sync BIOS' in Freegosy puts BIOS files in .../retroarch/BIOS. I believe Retroarch uses .../retroarch/RetroArch-Win64/system for BIOS files.
+
+I'm mostly only gonna use the above 2 but ran through the rest anyway so no missing gaps for you in the future hopefully!
+
+Eden/Azahar/Duckstation - Freegosy blackscreen after choosing package to download like PCSX2. Seems to be all that have that 'Select emulator version' popup get this issue. Required restart, download was OK.
+
+Ryujinx - No packages offered in 'Select emulator version' popup.
+
+<img width="626" height="351" alt="Image" src="https://github.com/user-attachments/assets/8d95bad5-a0ba-462d-b2fd-11aced1ca53c" />
+
+RPCS3 - No download
+
+<img width="600" height="48" alt="Image" src="https://github.com/user-attachments/assets/b15d5f95-598d-423f-9e67-d72675524a11" />
+
+MAME - No download
+
+<img width="493" height="237" alt="Image" src="https://github.com/user-attachments/assets/add6dd64-5321-494b-8541-d9f010f773dc" />
+
+The others not mentioned seemed fine. They downloaded and was able to open them.
+
+One other thing I noticed not related to emulator install. I noticed that a retroarch SNES game save from Freegosy was in ROMM as a zip file. I don't believe that there's any actual problem with that (I should probably try and load that save in grout or argosy) but I believe grout/argosy dont zip single file saves, only multi file/folder saves eg PSP, PS2, so SNES gets uploaded to ROMM as a .srm file. Might be an idea to touch base with their devs and keep save handling similar to ensure save compatibility between apps/launchers on different platforms and help make ROMM even more awesome by having bits like that more standardised.
+
+So yeah keep up the good work and let me know if you need any more info.
diff --git a/jobs.json b/jobs.json
deleted file mode 100644
index ed60395..0000000
--- a/jobs.json
+++ /dev/null
@@ -1 +0,0 @@
-{"jobs":[{"completedAt":"2026-05-07T13:14:32Z","conclusion":"success","databaseId":74822265631,"name":"Build Linux","startedAt":"2026-05-07T13:10:17Z","status":"completed","steps":[{"completedAt":"2026-05-07T13:10:19Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-05-07T13:10:18Z","status":"completed"},{"completedAt":"2026-05-07T13:10:20Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-05-07T13:10:19Z","status":"completed"},{"completedAt":"2026-05-07T13:10:35Z","conclusion":"success","name":"Run subosito/flutter-action@v2","number":3,"startedAt":"2026-05-07T13:10:20Z","status":"completed"},{"completedAt":"2026-05-07T13:13:36Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-05-07T13:10:35Z","status":"completed"},{"completedAt":"2026-05-07T13:14:22Z","conclusion":"success","name":"Build Linux","number":5,"startedAt":"2026-05-07T13:13:36Z","status":"completed"},{"completedAt":"2026-05-07T13:14:25Z","conclusion":"success","name":"Package Linux","number":6,"startedAt":"2026-05-07T13:14:22Z","status":"completed"},{"completedAt":"2026-05-07T13:14:28Z","conclusion":"success","name":"Build AppImage","number":7,"startedAt":"2026-05-07T13:14:25Z","status":"completed"},{"completedAt":"2026-05-07T13:14:30Z","conclusion":"success","name":"Upload Linux Artifact","number":8,"startedAt":"2026-05-07T13:14:28Z","status":"completed"},{"completedAt":"2026-05-07T13:14:31Z","conclusion":"success","name":"Post Run subosito/flutter-action@v2","number":15,"startedAt":"2026-05-07T13:14:30Z","status":"completed"},{"completedAt":"2026-05-07T13:14:31Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":16,"startedAt":"2026-05-07T13:14:31Z","status":"completed"},{"completedAt":"2026-05-07T13:14:31Z","conclusion":"success","name":"Complete job","number":17,"startedAt":"2026-05-07T13:14:31Z","status":"completed"}],"url":"https://github.com/abduznik/Freegosy/actions/runs/25497843287/job/74822265631"},{"completedAt":"2026-05-07T13:14:04Z","conclusion":"failure","databaseId":74822265637,"name":"Build Windows Installer","startedAt":"2026-05-07T13:10:16Z","status":"completed","steps":[{"completedAt":"2026-05-07T13:10:19Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-05-07T13:10:17Z","status":"completed"},{"completedAt":"2026-05-07T13:10:24Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-05-07T13:10:19Z","status":"completed"},{"completedAt":"2026-05-07T13:12:08Z","conclusion":"success","name":"Run subosito/flutter-action@v2","number":3,"startedAt":"2026-05-07T13:10:24Z","status":"completed"},{"completedAt":"2026-05-07T13:12:18Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-05-07T13:12:08Z","status":"completed"},{"completedAt":"2026-05-07T13:13:59Z","conclusion":"success","name":"Build Windows Installer","number":5,"startedAt":"2026-05-07T13:12:18Z","status":"completed"},{"completedAt":"2026-05-07T13:14:00Z","conclusion":"failure","name":"Create Zip (Windows)","number":6,"startedAt":"2026-05-07T13:13:59Z","status":"completed"},{"completedAt":"2026-05-07T13:14:00Z","conclusion":"skipped","name":"Upload Windows Artifact","number":7,"startedAt":"2026-05-07T13:14:00Z","status":"completed"},{"completedAt":"2026-05-07T13:14:00Z","conclusion":"success","name":"Post Run subosito/flutter-action@v2","number":13,"startedAt":"2026-05-07T13:14:00Z","status":"completed"},{"completedAt":"2026-05-07T13:14:03Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":14,"startedAt":"2026-05-07T13:14:00Z","status":"completed"},{"completedAt":"2026-05-07T13:14:03Z","conclusion":"success","name":"Complete job","number":15,"startedAt":"2026-05-07T13:14:03Z","status":"completed"}],"url":"https://github.com/abduznik/Freegosy/actions/runs/25497843287/job/74822265637"},{"completedAt":"2026-05-07T13:13:13Z","conclusion":"success","databaseId":74822265675,"name":"Build macOS DMG","startedAt":"2026-05-07T13:10:18Z","status":"completed","steps":[{"completedAt":"2026-05-07T13:10:20Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-05-07T13:10:18Z","status":"completed"},{"completedAt":"2026-05-07T13:10:21Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-05-07T13:10:20Z","status":"completed"},{"completedAt":"2026-05-07T13:11:10Z","conclusion":"success","name":"Run subosito/flutter-action@v2","number":3,"startedAt":"2026-05-07T13:10:21Z","status":"completed"},{"completedAt":"2026-05-07T13:11:12Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-05-07T13:11:10Z","status":"completed"},{"completedAt":"2026-05-07T13:12:41Z","conclusion":"success","name":"Build macOS","number":5,"startedAt":"2026-05-07T13:11:12Z","status":"completed"},{"completedAt":"2026-05-07T13:12:42Z","conclusion":"success","name":"Ad-hoc sign macOS app","number":6,"startedAt":"2026-05-07T13:12:41Z","status":"completed"},{"completedAt":"2026-05-07T13:12:57Z","conclusion":"success","name":"Create DMG","number":7,"startedAt":"2026-05-07T13:12:42Z","status":"completed"},{"completedAt":"2026-05-07T13:13:04Z","conclusion":"success","name":"Create Zip (macOS)","number":8,"startedAt":"2026-05-07T13:12:57Z","status":"completed"},{"completedAt":"2026-05-07T13:13:09Z","conclusion":"success","name":"Upload macOS Artifact","number":9,"startedAt":"2026-05-07T13:13:04Z","status":"completed"},{"completedAt":"2026-05-07T13:13:10Z","conclusion":"success","name":"Post Run subosito/flutter-action@v2","number":17,"startedAt":"2026-05-07T13:13:09Z","status":"completed"},{"completedAt":"2026-05-07T13:13:10Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":18,"startedAt":"2026-05-07T13:13:10Z","status":"completed"},{"completedAt":"2026-05-07T13:13:11Z","conclusion":"success","name":"Complete job","number":19,"startedAt":"2026-05-07T13:13:10Z","status":"completed"}],"url":"https://github.com/abduznik/Freegosy/actions/runs/25497843287/job/74822265675"},{"completedAt":"2026-05-07T13:14:47Z","conclusion":"failure","databaseId":74823055283,"name":"Create Release","startedAt":"2026-05-07T13:14:42Z","status":"completed","steps":[{"completedAt":"2026-05-07T13:14:44Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-05-07T13:14:43Z","status":"completed"},{"completedAt":"2026-05-07T13:14:44Z","conclusion":"success","name":"Create artifacts directory","number":2,"startedAt":"2026-05-07T13:14:44Z","status":"completed"},{"completedAt":"2026-05-07T13:14:45Z","conclusion":"success","name":"Download Linux Artifact","number":3,"startedAt":"2026-05-07T13:14:44Z","status":"completed"},{"completedAt":"2026-05-07T13:14:46Z","conclusion":"failure","name":"Download Windows Artifact","number":4,"startedAt":"2026-05-07T13:14:45Z","status":"completed"},{"completedAt":"2026-05-07T13:14:46Z","conclusion":"skipped","name":"Download macOS Artifact","number":5,"startedAt":"2026-05-07T13:14:46Z","status":"completed"},{"completedAt":"2026-05-07T13:14:46Z","conclusion":"skipped","name":"Create Release","number":6,"startedAt":"2026-05-07T13:14:46Z","status":"completed"},{"completedAt":"2026-05-07T13:14:46Z","conclusion":"success","name":"Complete job","number":7,"startedAt":"2026-05-07T13:14:46Z","status":"completed"}],"url":"https://github.com/abduznik/Freegosy/actions/runs/25497843287/job/74823055283"}]}
diff --git a/lib/app.dart b/lib/app.dart
index 44a3e7f..6476a1f 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'main.dart' show scaffoldMessengerKey;
@@ -13,6 +14,10 @@ import 'ui/screens/settings_screen.dart';
import 'ui/screens/onboarding_screen.dart';
import 'providers/ui_provider.dart';
import 'core/storage/file_sanity_service.dart';
+import 'core/input/gamepad_service.dart';
+import 'core/input/input_action_bus.dart';
+import 'package:flutter/services.dart';
+import 'providers/theme_provider.dart';
class CustomScrollBehavior extends MaterialScrollBehavior {
@override
@@ -32,6 +37,91 @@ class FreegosyApp extends ConsumerStatefulWidget {
}
class _FreegosyAppState extends ConsumerState<FreegosyApp> {
+ StreamSubscription<GameAction>? _inputSub;
+
+ @override
+ void initState() {
+ super.initState();
+
+ // Global Keyboard Listener - Maps physical keys to Action Bus commands
+ HardwareKeyboard.instance.addHandler((event) {
+ if (event is KeyDownEvent) {
+ // Protection: Ignore if typing in a text field
+ final focusNode = FocusManager.instance.primaryFocus;
+ if (focusNode != null) {
+ bool isTyping = false;
+ if (focusNode.context?.widget is EditableText) {
+ isTyping = true;
+ } else {
+ focusNode.context?.visitAncestorElements((element) {
+ if (element.widget is EditableText) {
+ isTyping = true;
+ return false;
+ }
+ return true;
+ });
+ }
+ if (isTyping) {
+ return false;
+ }
+ }
+
+ final action = _mapPhysicalKeyToAction(event.logicalKey);
+ if (action != null) {
+ // 1. Switch to Keyboard Mode
+ if (ref.read(inputModeProvider) != InputMode.keyboard) {
+ debugPrint('⌨️ Switching to KEYBOARD mode.');
+ ref.read(inputModeProvider.notifier).state = InputMode.keyboard;
+ }
+
+ // 2. Broadcast the action
+ inputActionBus.add(action);
+ }
+ }
+ return false;
+ });
+
+ // Global Action Executor: Listen for actions that apply everywhere
+ _inputSub = inputActionBus.stream.listen((action) {
+ if (action == GameAction.confirm) {
+ final focusedAction = ref.read(focusedActionProvider);
+ if (focusedAction != null) {
+ debugPrint('🎯 Global: Executing focused action.');
+ focusedAction();
+ }
+ } else if (action == GameAction.l1) {
+ final current = ref.read(currentTabIndexProvider);
+ if (current > 0) {
+ ref.read(currentTabIndexProvider.notifier).state = current - 1;
+ }
+ } else if (action == GameAction.r1) {
+ final current = ref.read(currentTabIndexProvider);
+ if (current < _screens.length - 1) {
+ ref.read(currentTabIndexProvider.notifier).state = current + 1;
+ }
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _inputSub?.cancel();
+ super.dispose();
+ }
+
+ GameAction? _mapPhysicalKeyToAction(LogicalKeyboardKey key) {
+ if (key == LogicalKeyboardKey.escape) return GameAction.back;
+ if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.space) return GameAction.confirm;
+ if (key == LogicalKeyboardKey.arrowUp) return GameAction.up;
+ if (key == LogicalKeyboardKey.arrowDown) return GameAction.down;
+ if (key == LogicalKeyboardKey.arrowLeft) return GameAction.left;
+ if (key == LogicalKeyboardKey.arrowRight) return GameAction.right;
+ if (key == LogicalKeyboardKey.keyX) return GameAction.detail;
+ if (key == LogicalKeyboardKey.keyY) return GameAction.favorite;
+ if (key == LogicalKeyboardKey.keyQ) return GameAction.l1;
+ if (key == LogicalKeyboardKey.keyE) return GameAction.r1;
+ return null;
+ }
final List<Widget> _screens = const [
@@ -75,8 +165,9 @@ class _FreegosyAppState extends ConsumerState<FreegosyApp> {
}
});
- // Keep the file sanity service alive and running in the background
+ // Keep services alive in the background
ref.watch(fileSanityServiceProvider);
+ ref.watch(gamepadServiceProvider);
final currentIndex = ref.watch(currentTabIndexProvider);
@@ -86,47 +177,7 @@ class _FreegosyAppState extends ConsumerState<FreegosyApp> {
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: scaffoldMessengerKey,
scrollBehavior: CustomScrollBehavior(),
- theme: ThemeData(
- useMaterial3: true,
- colorScheme: ColorScheme.fromSeed(
- seedColor: Colors.deepPurple,
- brightness: Brightness.dark,
- surface: const Color(0xFF1a1a1a),
- ),
- scaffoldBackgroundColor: const Color(0xFF0f0f0f),
- cardTheme: const CardThemeData(
- color: Color(0xFF1a1a1a),
- elevation: 2,
- ),
- appBarTheme: const AppBarTheme(
- backgroundColor: Color(0xFF0f0f0f),
- foregroundColor: Colors.white,
- elevation: 0,
- surfaceTintColor: Colors.transparent,
- ),
- navigationBarTheme: NavigationBarThemeData(
- backgroundColor: const Color(0xFF1a1a1a),
- indicatorColor: Colors.deepPurple.withValues(alpha: 0.3),
- ),
- inputDecorationTheme: InputDecorationTheme(
- filled: true,
- fillColor: const Color(0xFF1a1a1a),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8),
- borderSide: BorderSide(color: Colors.deepPurple.shade800),
- ),
- enabledBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8),
- borderSide: BorderSide(color: Colors.deepPurple.shade900),
- ),
- ),
- elevatedButtonTheme: ElevatedButtonThemeData(
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.deepPurple,
- foregroundColor: Colors.white,
- ),
- ),
- ),
+ theme: getThemeData(ref.watch(themeProvider)),
home: Consumer(
builder: (context, ref, _) {
final isOnboardedAsync = ref.watch(rommConfigProvider);
diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart
index 3b746ea..f3a6993 100644
--- a/lib/core/constants/app_constants.dart
+++ b/lib/core/constants/app_constants.dart
@@ -1,3 +1,3 @@
class AppConstants {
- static const String version = '0.4.1'; // Unified version
+ static const String version = '0.5.0'; // Unified version
}
diff --git a/lib/core/constants/gaming_quotes.dart b/lib/core/constants/gaming_quotes.dart
new file mode 100644
index 0000000..66f4612
--- /dev/null
+++ b/lib/core/constants/gaming_quotes.dart
@@ -0,0 +1,129 @@
+class GamingQuote {
+ final String quote;
+ final String game;
+
+ const GamingQuote({required this.quote, required this.game});
+}
+
+class GamingQuotes {
+ static const List<GamingQuote> quotes = [
+ GamingQuote(quote: "Kept you waiting, huh?", game: "Metal Gear Solid"),
+ GamingQuote(quote: "It's dangerous to go alone! Take this.", game: "The Legend of Zelda"),
+ GamingQuote(quote: "The cake is a lie.", game: "Portal"),
+ GamingQuote(quote: "War... war never changes.", game: "Fallout"),
+ GamingQuote(quote: "Would you kindly?", game: "BioShock"),
+ GamingQuote(quote: "Thank you Mario! But our princess is in another castle!", game: "Super Mario Bros."),
+ GamingQuote(quote: "What is a man? A miserable little pile of secrets!", game: "Castlevania: SotN"),
+ GamingQuote(quote: "Praise the Sun!", game: "Dark Souls"),
+ GamingQuote(quote: "Hey! Listen!", game: "The Legend of Zelda: Ocarina of Time"),
+ GamingQuote(quote: "You Died.", game: "Dark Souls"),
+ GamingQuote(quote: "Snake? Snake!? SNAAAAAAKE!", game: "Metal Gear Solid"),
+ GamingQuote(quote: "Do a barrel roll!", game: "Star Fox 64"),
+ GamingQuote(quote: "Rise and shine, Mr. Freeman.", game: "Half-Life 2"),
+ GamingQuote(quote: "Nothing is true, everything is permitted.", game: "Assassin's Creed"),
+ GamingQuote(quote: "Protocol 3: Protect the Pilot.", game: "Titanfall 2"),
+ GamingQuote(quote: "Our destiny is not written for us, but by us.", game: "Halo 4"),
+ GamingQuote(quote: "You cannot kill me! I am sub-human!", game: "Devil May Cry V"),
+ GamingQuote(quote: "A man chooses, a slave obeys.", game: "BioShock"),
+ GamingQuote(quote: "I used to be an adventurer like you, then I took an arrow in the knee.", game: "The Elder Scrolls V: Skyrim"),
+ GamingQuote(quote: "Hadouken!", game: "Street Fighter"),
+ GamingQuote(quote: "Finish Him!", game: "Mortal Kombat"),
+ GamingQuote(quote: "The right man in the wrong place can make all the difference in the world.", game: "Half-Life 2"),
+ GamingQuote(quote: "All your base are belong to us.", game: "Zero Wing"),
+ GamingQuote(quote: "You standard-model humans are so fragile.", game: "Portal 2"),
+ GamingQuote(quote: "It is pitch black. You are likely to be eaten by a grue.", game: "Zork"),
+ GamingQuote(quote: "I am Error.", game: "Zelda II: The Adventure of Link"),
+ GamingQuote(quote: "Wake me, when you need me.", game: "Halo 3"),
+ GamingQuote(quote: "A famous explorer once said that the extraordinary is in what we do, not who we are.", game: "Tomb Raider"),
+ GamingQuote(quote: "It's time to kick ass and chew bubble gum... and I'm all out of gum.", game: "Duke Nukem 3D"),
+ GamingQuote(quote: "The strength of a soldier comes from the drive in his heart.", game: "Crisis Core: Final Fantasy VII"),
+ GamingQuote(quote: "No cost too great. No mind to think. No will to break.", game: "Hollow Knight"),
+ GamingQuote(quote: "Despite everything, it's still you.", game: "Undertale"),
+ GamingQuote(quote: "In a world without gold, we might've been heroes.", game: "Assassin's Creed IV: Black Flag"),
+ GamingQuote(quote: "Stand in the ashes of a trillion dead souls, and ask the ghosts if honor matters.", game: "Mass Effect 3"),
+ GamingQuote(quote: "What is better? To be born good or to overcome your evil nature through great effort?", game: "The Elder Scrolls V: Skyrim"),
+ GamingQuote(quote: "I survive because the fire inside burns brighter than the fire around me.", game: "Fallout: New Vegas"),
+ GamingQuote(quote: "You are filled with determination.", game: "Undertale"),
+ GamingQuote(quote: "We work in the dark to serve the light.", game: "Assassin's Creed"),
+ GamingQuote(quote: "Every puzzle has an answer.", game: "Professor Layton and the Curious Village"),
+ GamingQuote(quote: "Get over here!", game: "Mortal Kombat"),
+ GamingQuote(quote: "Science isn't about WHY. It's about WHY NOT!", game: "Portal 2"),
+ GamingQuote(quote: "Good men mean well. We just don't always end up doing well.", game: "Dead Space 3"),
+ GamingQuote(quote: "Endure and survive.", game: "The Last of Us"),
+ GamingQuote(quote: "History is written by the victors.", game: "Call of Duty: Modern Warfare 2"),
+ GamingQuote(quote: "If the world chooses to become my enemy, I will fight just as I always have.", game: "Sonic the Hedgehog"),
+ GamingQuote(quote: "Even in the darkest times, there is always a way.", game: "Kingdom Hearts"),
+ GamingQuote(quote: "Some tree standing in a forest doesn't make a world.", game: "Disco Elysium"),
+ GamingQuote(quote: "Don't wish it were easier. Wish you were better.", game: "Animal Crossing"),
+ GamingQuote(quote: "May the Triforce be with you.", game: "The Legend of Zelda"),
+ GamingQuote(quote: "Booker, are you afraid of God?", game: "BioShock Infinite"),
+ GamingQuote(quote: "You've met with a terrible fate, haven't you?", game: "The Legend of Zelda: Majora's Mask"),
+ GamingQuote(quote: "You have died of dysentery.", game: "The Oregon Trail"),
+ GamingQuote(quote: "I am Andrew Ryan, and I'm here to ask you a question.", game: "BioShock"),
+ GamingQuote(quote: "The light of the campfire warm our bones.", game: "Dark Souls"),
+ GamingQuote(quote: "The healthy human mind doesn't wake up in the morning thinking this is its last day on Earth.", game: "Call of Duty: Modern Warfare 2"),
+ GamingQuote(quote: "We all make choices, but in the end our choices make us.", game: "BioShock"),
+ GamingQuote(quote: "I require more Vespene Gas.", game: "StarCraft"),
+ GamingQuote(quote: "Construct additional Pylons.", game: "StarCraft"),
+ GamingQuote(quote: "Steel wins battles, gold wins wars.", game: "Dota 2"),
+ GamingQuote(quote: "The world is a cruel place, and we're all just trying to survive.", game: "Red Dead Redemption"),
+ GamingQuote(quote: "If you find yourself in a dark place, remember that you are the light.", game: "Alan Wake"),
+ GamingQuote(quote: "Nothing can stop us now.", game: "Grand Theft Auto: San Andreas"),
+ GamingQuote(quote: "The path of the righteous man is beset on all sides.", game: "Pulp Fiction / Payday 2"),
+ GamingQuote(quote: "May your heart be your guiding key.", game: "Kingdom Hearts"),
+ GamingQuote(quote: "There are no heroes here. Only survivors.", game: "Spec Ops: The Line"),
+ GamingQuote(quote: "Ah, shit, here we go again.", game: "Grand Theft Auto: San Andreas"),
+ GamingQuote(quote: "It's a beautiful day outside. Birds are singing, flowers are blooming...", game: "Undertale"),
+ GamingQuote(quote: "Boy!", game: "God of War"),
+ GamingQuote(quote: "I'm looking for a man named Spencer.", game: "Resident Evil"),
+ GamingQuote(quote: "The master of unlocking.", game: "Resident Evil"),
+ GamingQuote(quote: "Stay awhile and listen!", game: "Diablo II"),
+ GamingQuote(quote: "You cannot pass!", game: "The Lord of the Rings: The Third Age"),
+ GamingQuote(quote: "Press F to pay respects.", game: "Call of Duty: Advanced Warfare"),
+ GamingQuote(quote: "It's a me, Mario!", game: "Super Mario 64"),
+ GamingQuote(quote: "Waka waka!", game: "Pac-Man"),
+ GamingQuote(quote: "No gods or kings. Only man.", game: "BioShock"),
+ GamingQuote(quote: "Behind you! A giant three-headed monkey!", game: "The Secret of Monkey Island"),
+ GamingQuote(quote: "A scatter of rust and plaster... and then the sea.", game: "Disco Elysium"),
+ GamingQuote(quote: "Home is where the hearth is.", game: "Minecraft"),
+ GamingQuote(quote: "You are not prepared!", game: "World of Warcraft"),
+ GamingQuote(quote: "I never asked for this.", game: "Deus Ex: Human Revolution"),
+ GamingQuote(quote: "Prepare for unforeseen consequences.", game: "Half-Life 2"),
+ GamingQuote(quote: "Defeat is not the worst of failures. Not to have tried is the true failure.", game: "Darkest Dungeon"),
+ GamingQuote(quote: "Remind yourself that overconfidence is a slow and insidious killer.", game: "Darkest Dungeon"),
+ GamingQuote(quote: "I am Malenia, Blade of Miquella.", game: "Elden Ring"),
+ GamingQuote(quote: "Foul tarnished, in search of the Elden Ring.", game: "Elden Ring"),
+ GamingQuote(quote: "Farewell, good hunter. May you find your worth in the waking world.", game: "Bloodborne"),
+ GamingQuote(quote: "We are born of the blood, made men by the blood, undone by the blood.", game: "Bloodborne"),
+ GamingQuote(quote: "Tonight, Gehrman joins the hunt.", game: "Bloodborne"),
+ GamingQuote(quote: "A hunter is a hunter, even in a dream.", game: "Bloodborne"),
+ GamingQuote(quote: "True victory is to give all of yourself for what you believe in.", game: "Final Fantasy XIV"),
+ GamingQuote(quote: "We must be better than this.", game: "God of War"),
+ GamingQuote(quote: "Death can have me, when it earns me.", game: "God of War: Ragnarök"),
+ GamingQuote(quote: "To the skies, See! The stars shine for you.", game: "Outer Wilds"),
+ GamingQuote(quote: "The universe is, and we are.", game: "Outer Wilds"),
+ GamingQuote(quote: "Be excellent to each other.", game: "Bill & Ted's Excellent Video Game Adventure"),
+ GamingQuote(quote: "The cosmos, of course!", game: "Bloodborne"),
+ GamingQuote(quote: "I have a plan!", game: "Red Dead Redemption 2"),
+ GamingQuote(quote: "Outlaws for life.", game: "Red Dead Redemption 2"),
+ GamingQuote(quote: "Ain't no such thing as civilized.", game: "Red Dead Redemption 2"),
+ GamingQuote(quote: "You're a good man, Arthur Morgan.", game: "Red Dead Redemption 2"),
+ GamingQuote(quote: "I'm a survivor, Black Lung. Real survivor.", game: "Red Dead Redemption 2"),
+ GamingQuote(quote: "War is when the young and stupid are tricked by the old and bitter.", game: "Grand Theft Auto IV"),
+ GamingQuote(quote: "So this is what the dream feels like. This is the victory we longed for.", game: "Grand Theft Auto IV"),
+ GamingQuote(quote: "Sometimes it's the journey that teaches you a lot about your destination.", game: "Uncharted: Drake's Fortune"),
+ GamingQuote(quote: "Greatness from small beginnings.", game: "Uncharted 3: Drake's Deception"),
+ GamingQuote(quote: "Sic Parvis Magna.", game: "Uncharted"),
+ GamingQuote(quote: "Don't make a girl a promise... if you know you can't keep it.", game: "Halo 2"),
+ GamingQuote(quote: "I am the monument to all your sins.", game: "Halo 2"),
+ GamingQuote(quote: "Were it so easy.", game: "Halo 3"),
+ GamingQuote(quote: "Spartans never die. They're just missing in action.", game: "Halo: Reach"),
+ GamingQuote(quote: "Remember Reach.", game: "Halo: Reach"),
+ GamingQuote(quote: "I think we're just getting started.", game: "Halo: Combat Evolved"),
+ GamingQuote(quote: "No, Sir. All mind, all heart, all soul.", game: "Titanfall 2"),
+ GamingQuote(quote: "Trust me.", game: "Titanfall 2"),
+ GamingQuote(quote: "Keep your eyes on the horizon.", game: "Sea of Thieves"),
+ GamingQuote(quote: "The light of day is a rare gift.", game: "Metro 2033"),
+ GamingQuote(quote: "The wind is howling.", game: "The Witcher 3: Wild Hunt"),
+ ];
+}
diff --git a/lib/core/emulator/emulator_download_service.dart b/lib/core/emulator/emulator_download_service.dart
index 6bada5b..fb37132 100644
--- a/lib/core/emulator/emulator_download_service.dart
+++ b/lib/core/emulator/emulator_download_service.dart
@@ -8,32 +8,45 @@ import '../downloader/download_service.dart';
import '../storage/directory_service.dart';
import '../extraction/extraction_service.dart';
import 'emulator_registry_data.dart';
+import 'strategy_registry.dart';
import 'release_service.dart';
class EmulatorDownloadService {
final Dio _dio;
- final DirectoryService _directoryService;
final ExtractionService _extractionService;
+ final DirectoryService _directoryService;
+ final StrategyRegistry _strategyRegistry;
late final ReleaseService _releaseService;
- EmulatorDownloadService(this._dio, this._directoryService, this._extractionService) {
+ EmulatorDownloadService(this._dio, this._directoryService, this._extractionService, this._strategyRegistry) {
_releaseService = ReleaseService(_dio);
}
Future<List<Map<String, String>>> getLatestAssetsForEmulator(String emulatorId) async {
final definition = kEmulatorDefinitions.firstWhere((d) => d['id'] == emulatorId);
final repo = definition['github_repo'] as String? ?? definition['gitea_repo'] as String? ?? '';
- final platform = definition['type'] == 'github' ? ReleasePlatform.github : (definition['type'] == 'dolphin' ? ReleasePlatform.dolphin : ReleasePlatform.gitea);
+ final type = definition['type'] as String? ?? 'github';
+ final platform = type == 'github' || type == 'github_multi' ? ReleasePlatform.github : (type == 'dolphin' ? ReleasePlatform.dolphin : ReleasePlatform.gitea);
+
+ String requiredKey;
+ String excludedKey;
- // Simplification: Re-use filters from definition
- final requiredKey = Platform.isWindows ? 'github_asset_required_windows' : 'github_asset_required_linux';
- final excludedKey = 'github_asset_excluded';
+ if (type == 'gitea') {
+ requiredKey = Platform.isWindows ? 'gitea_asset_required_windows' : 'gitea_asset_required_linux';
+ excludedKey = 'gitea_asset_excluded';
+ } else {
+ requiredKey = Platform.isWindows ? 'github_asset_required_windows' : 'github_asset_required_linux';
+ excludedKey = 'github_asset_excluded';
+ }
+ final baseUrl = definition['gitea_host'] != null ? 'https://${definition['gitea_host']}' : null;
+
return await _releaseService.getLatestReleaseAssets(
platform: platform,
repo: repo,
requiredFilters: List<String>.from(definition[requiredKey] ?? []),
excludedFilters: List<String>.from(definition[excludedKey] ?? []),
+ baseUrl: baseUrl,
);
}
@@ -164,7 +177,7 @@ class EmulatorDownloadService {
}
}
- if (downloadUrl == null && type == 'github') {
+ if (downloadUrl == null && (type == 'github' || type == 'github_multi')) {
String repo = definition['github_repo'] as String;
@@ -347,6 +360,12 @@ class EmulatorDownloadService {
}
}
}
+
+ // Run post-install hooks
+ final strategy = _strategyRegistry.getStrategyById(emulatorId);
+ if (strategy != null) {
+ await strategy.postInstall(emulatorDir);
+ }
controller.add(DownloadProgress(
id: emulatorId,
diff --git a/lib/core/emulator/emulator_registry_data.dart b/lib/core/emulator/emulator_registry_data.dart
index 064306f..0e1a365 100644
--- a/lib/core/emulator/emulator_registry_data.dart
+++ b/lib/core/emulator/emulator_registry_data.dart
@@ -3,7 +3,7 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'id': 'retroarch',
'name': 'RetroArch',
'type': 'direct',
- 'windows_url': 'https://buildbot.libretro.com/stable/1.20.0/windows/x86_64/RetroArch.7z',
+ 'windows_url': 'https://buildbot.libretro.com/stable/1.22.2/windows/x86_64/RetroArch.7z',
'windows_executable': 'RetroArch.exe',
'linux_url': 'https://buildbot.libretro.com/stable/1.22.2/linux/x86_64/RetroArch.7z',
'linux_executable': 'retroarch',
@@ -45,14 +45,14 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
{
'id': 'ryujinx',
'name': 'Ryujinx (Switch)',
- 'type': 'gitea',
- 'gitea_host': 'git.ryujinx.app',
- 'gitea_repo': 'Ryubing/Canary',
- 'gitea_asset_required_windows': ['win_x64'],
- 'gitea_asset_required_macos': ['macos_universal'],
- 'gitea_asset_required_linux': ['linux_x64'],
+ 'type': 'github',
+ 'github_repo': 'Ryujinx/Ryujinx',
+ 'github_asset_required_windows': ['win_x64'],
+ 'github_asset_required_linux': ['linux_x64'],
+ 'github_asset_required_macos': ['macos_universal'],
+ 'github_asset_excluded': ['debug', 'source'],
'windows_executable': 'Ryujinx.exe',
- 'linux_executable': 'Ryujinx.AppImage',
+ 'linux_executable': 'Ryujinx',
'macos_executable': 'Ryujinx.app/Contents/MacOS/Ryujinx',
'platform_slugs': ['switch', 'nintendo-switch', 'ns'],
'supported_platforms': ['windows', 'linux', 'macos'],
@@ -60,12 +60,10 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
{
'id': 'rpcs3',
'name': 'RPCS3 (PlayStation 3)',
- 'type': 'github_multi',
- 'github_repos': {
- 'windows': 'RPCS3/rpcs3-binaries-win',
- 'macos': 'RPCS3/rpcs3-binaries-mac',
- 'linux': 'RPCS3/rpcs3-binaries-linux',
- },
+ 'type': 'github',
+ 'github_repo_windows': 'RPCS3/rpcs3-binaries-win',
+ 'github_repo_macos': 'RPCS3/rpcs3-binaries-mac',
+ 'github_repo_linux': 'RPCS3/rpcs3-binaries-linux',
'github_asset_required_windows': ['.7z'],
'github_asset_required_macos': ['.7z'],
'github_asset_required_linux': ['.AppImage'],
@@ -236,7 +234,8 @@ const List<Map<String, dynamic>> kEmulatorDefinitions = [
'name': 'MAME',
'type': 'github',
'github_repo': 'mamedev/mame',
- 'github_asset_required_windows': ['mame', 'x64', '.exe'],
+ 'github_asset_required_windows': ['mame', '64', '.exe'],
+ 'github_asset_excluded_windows': ['arm64'],
'github_asset_required_linux': ['mame', 'lx', '.zip'],
'github_asset_excluded': ['debug', 'symbols', 'sources', 'tools'],
'extraction_type': 'self_extracting',
diff --git a/lib/core/emulator/emulator_strategy.dart b/lib/core/emulator/emulator_strategy.dart
index d80d137..a35f594 100644
--- a/lib/core/emulator/emulator_strategy.dart
+++ b/lib/core/emulator/emulator_strategy.dart
@@ -1,4 +1,4 @@
-import 'dart:io';
+import 'dart:io';
import 'dart:io' as io;
import 'package:path/path.dart' as p;
import 'package:freegosy/core/romm/romm_models.dart';
@@ -57,6 +57,9 @@ abstract class EmulatorStrategy {
Future<void> preLaunch(Game game, String romPath) async {}
Future<void> postLaunch(Game game, String romPath) async {}
+ /// Hook called after the emulator has been downloaded and extracted.
+ Future<void> postInstall(String installDir) async {}
+
Future<void> launchStandalone() async {
final exePath = await findExecutable();
if (exePath == null) throw Exception('$name not found. Please download it first.');
diff --git a/lib/core/emulator/strategies/duckstation_strategy.dart b/lib/core/emulator/strategies/duckstation_strategy.dart
index 437fa8f..faf6e5b 100644
--- a/lib/core/emulator/strategies/duckstation_strategy.dart
+++ b/lib/core/emulator/strategies/duckstation_strategy.dart
@@ -1,7 +1,9 @@
import 'dart:io' as io;
+import 'package:flutter/foundation.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';
+import 'package:path/path.dart' as p;
class DuckstationStrategy extends EmulatorStrategy {
final DirectoryService _directoryService;
@@ -12,7 +14,7 @@ class DuckstationStrategy extends EmulatorStrategy {
DirectoryService get directoryService => _directoryService;
@override
- List<String> get launchArgs => ['-batch', '-fullname'];
+ List<String> get launchArgs => ['-batch', '-fullscreen'];
@override
String get name => 'DuckStation';
@@ -45,4 +47,19 @@ class DuckstationStrategy extends EmulatorStrategy {
}
return '';
}
+
+ @override
+ Future<void> postInstall(String installDir) async {
+ if (io.Platform.isWindows) {
+ // DuckStation requires a portable.txt file in the SAME directory as the executable to run in portable mode.
+ final exePath = await _directoryService.findEmulatorExecutable(emulatorId, windowsExecutable);
+ final targetDir = exePath != null ? io.File(exePath).parent.path : installDir;
+
+ final portableTxt = io.File(p.join(targetDir, 'portable.txt'));
+ if (!await portableTxt.exists()) {
+ await portableTxt.create();
+ debugPrint('[DuckStation] Created portable.txt at $targetDir to enable portable mode.');
+ }
+ }
+ }
}
diff --git a/lib/core/emulator/strategies/pcsx2_strategy.dart b/lib/core/emulator/strategies/pcsx2_strategy.dart
index 42d0ae8..148e906 100644
--- a/lib/core/emulator/strategies/pcsx2_strategy.dart
+++ b/lib/core/emulator/strategies/pcsx2_strategy.dart
@@ -1,4 +1,6 @@
import 'dart:io' as io;
+import 'package:flutter/foundation.dart';
+import 'package:path/path.dart' as p;
import 'package:freegosy/core/emulator/emulator_strategy.dart';
import 'package:freegosy/core/romm/romm_models.dart';
import 'package:freegosy/core/storage/directory_service.dart';
@@ -45,4 +47,18 @@ class Pcsx2Strategy extends EmulatorStrategy {
}
return '';
}
+
+ @override
+ Future<void> postInstall(String installDir) async {
+ // PCSX2 requires a portable.ini file in the SAME directory as the executable to run in portable mode.
+ // This ensures saves and settings are stored in the emulator directory.
+ final exePath = await _directoryService.findEmulatorExecutable(emulatorId, windowsExecutable);
+ final targetDir = exePath != null ? io.File(exePath).parent.path : installDir;
+
+ final portableIni = io.File(p.join(targetDir, 'portable.ini'));
+ if (!await portableIni.exists()) {
+ await portableIni.create();
+ debugPrint('[PCSX2] Created portable.ini at $targetDir to enable portable mode.');
+ }
+ }
}
diff --git a/lib/core/input/gamepad_service.dart b/lib/core/input/gamepad_service.dart
new file mode 100644
index 0000000..60c41be
--- /dev/null
+++ b/lib/core/input/gamepad_service.dart
@@ -0,0 +1,265 @@
+import 'dart:async';
+import 'package:flutter/widgets.dart';
+import 'package:gamepads/gamepads.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../providers/ui_provider.dart';
+import 'known_controllers.dart';
+import 'sdl_parser.dart';
+import 'input_action_bus.dart';
+
+enum GameAction {
+ up, down, left, right,
+ confirm, back, detail, favorite,
+ verticalAxis, horizontalAxis,
+ l1, r1, l2, r2, start, select
+}
+
+class NormalizedInput {
+ final GameAction action;
+ final double value;
+ const NormalizedInput({required this.action, required this.value});
+}
+
+final gamepadServiceProvider = Provider<GamepadService>((ref) {
+ final service = GamepadService(ref);
+ service.initialize();
+ return service;
+});
+
+class AxisState {
+ bool isNegativeActive = false;
+ bool isPositiveActive = false;
+}
+
+class GamepadService {
+ final Ref _ref;
+ StreamSubscription<GamepadEvent>? _subscription;
+ Timer? _scanTimer;
+ final Map<String, String> _controllerNames = {};
+ final Map<String, AxisState> _axisStates = {};
+
+ GameAction? _heldDirection;
+ Timer? _holdDelayTimer;
+ Timer? _holdRepeatTimer;
+
+ GamepadService(this._ref);
+
+ void initialize() async {
+ debugPrint('🎮 GamepadService: Starting direct action listener...');
+
+ // Load the SDL Database
+ await SDLMappingParser.loadDatabase();
+
+ _scan();
+ _scanTimer = Timer.periodic(const Duration(seconds: 3), (_) => _scan());
+
+ _subscription = Gamepads.events.listen(
+ (event) {
+ _handleGamepadEvent(event);
+ },
+ onError: (err) => debugPrint('🎮 Gamepad Stream Error: $err'),
+ );
+ }
+
+ void _scan() async {
+ final controllers = await Gamepads.list();
+ for (var c in controllers) {
+ if (!_controllerNames.containsKey(c.id)) {
+ debugPrint('🎮 GamepadService: New controller detected! [${c.id}] ${c.name}');
+ _controllerNames[c.id] = c.name;
+ }
+ }
+ }
+
+ Map<String, GameAction> _getMappingFor(String controllerId) {
+ final name = _controllerNames[controllerId] ?? '';
+
+ // 1. Try SDL Database first (Universal)
+ final sdlMapping = SDLMappingParser.getMapping(name);
+ if (sdlMapping != null) return sdlMapping;
+
+ // 2. Try our hardcoded known_controllers
+ for (final entry in kControllerMappings.entries) {
+ if (name.toLowerCase().contains(entry.key.toLowerCase())) {
+ return entry.value;
+ }
+ }
+
+ // 3. Fallback
+ return kDefaultMapping;
+ }
+
+ NormalizedInput? _normalize(GamepadEvent event) {
+ final mapping = _getMappingFor(event.gamepadId);
+ final action = mapping[event.key];
+ if (action != null) {
+ return NormalizedInput(action: action, value: event.value);
+ }
+ return null;
+ }
+
+ void _handleGamepadEvent(GamepadEvent event) {
+ // 1. Switch to Gamepad input mode if significant event occurs
+ if (_ref.read(inputModeProvider) != InputMode.gamepad) {
+ if (event.value.abs() > 0.5) {
+ debugPrint('🎮 Switching to GAMEPAD mode.');
+ _ref.read(inputModeProvider.notifier).state = InputMode.gamepad;
+ }
+ }
+
+ final normalized = _normalize(event);
+
+ // --- SMART LOGGING ---
+ if (normalized == null && event.value.abs() > 0.5) {
+ debugPrint('🎮 UNMAPPED HID [${event.gamepadId}]: ${event.key} = ${event.value}');
+ }
+
+ if (normalized == null) return;
+
+ // 2. Handle Axes (Analog Sticks & D-Pad Axes)
+ if (normalized.action == GameAction.horizontalAxis || normalized.action == GameAction.verticalAxis) {
+ final axisKey = '${event.gamepadId}_${event.key}';
+ final state = _axisStates.putIfAbsent(axisKey, () => AxisState());
+
+ // Invert vertical D-pad axis because D-pad UP is positive on macOS,
+ // but analog stick UP is negative. This unifies their behavior.
+ final bool isInverted = event.key.contains('dpad') && normalized.action == GameAction.verticalAxis;
+ final double adjustedValue = isInverted ? -event.value : event.value;
+
+ // Negative threshold (-0.5)
+ if (adjustedValue < -0.5) {
+ if (!state.isNegativeActive) {
+ state.isNegativeActive = true;
+ final mappedAction = (normalized.action == GameAction.horizontalAxis)
+ ? GameAction.left
+ : GameAction.up;
+ _triggerAction(mappedAction, adjustedValue);
+ _activateDirection(mappedAction);
+ }
+ } else if (adjustedValue > -0.15) {
+ if (state.isNegativeActive) {
+ state.isNegativeActive = false;
+ final mappedAction = (normalized.action == GameAction.horizontalAxis)
+ ? GameAction.left
+ : GameAction.up;
+ _deactivateDirection(mappedAction);
+ }
+ }
+
+ // Positive threshold (0.5)
+ if (adjustedValue > 0.5) {
+ if (!state.isPositiveActive) {
+ state.isPositiveActive = true;
+ final mappedAction = (normalized.action == GameAction.horizontalAxis)
+ ? GameAction.right
+ : GameAction.down;
+ _triggerAction(mappedAction, adjustedValue);
+ _activateDirection(mappedAction);
+ }
+ } else if (adjustedValue < 0.15) {
+ if (state.isPositiveActive) {
+ state.isPositiveActive = false;
+ final mappedAction = (normalized.action == GameAction.horizontalAxis)
+ ? GameAction.right
+ : GameAction.down;
+ _deactivateDirection(mappedAction);
+ }
+ }
+ } else {
+ // 3. Handle Digital Buttons
+ if (event.value.abs() > 0.5) {
+ _triggerAction(normalized.action, event.value);
+ if (_isDirectionAction(normalized.action)) {
+ _activateDirection(normalized.action);
+ }
+ } else {
+ if (_isDirectionAction(normalized.action)) {
+ _deactivateDirection(normalized.action);
+ }
+ }
+ }
+ }
+
+ bool _isDirectionAction(GameAction action) {
+ return action == GameAction.up ||
+ action == GameAction.down ||
+ action == GameAction.left ||
+ action == GameAction.right;
+ }
+
+ void _activateDirection(GameAction action) {
+ if (_heldDirection == action) return;
+
+ _cancelHoldTimers();
+ _heldDirection = action;
+
+ // Start delay timer for 500ms (half a second)
+ _holdDelayTimer = Timer(const Duration(milliseconds: 500), () {
+ // After 500ms delay, start repeating every 120ms
+ _holdRepeatTimer = Timer.periodic(const Duration(milliseconds: 120), (timer) {
+ if (_heldDirection == action) {
+ _triggerAction(action, 1.0);
+ } else {
+ timer.cancel();
+ }
+ });
+ });
+ }
+
+ void _deactivateDirection(GameAction action) {
+ if (_heldDirection == action) {
+ _cancelHoldTimers();
+ _heldDirection = null;
+ }
+ }
+
+ void _cancelHoldTimers() {
+ _holdDelayTimer?.cancel();
+ _holdDelayTimer = null;
+ _holdRepeatTimer?.cancel();
+ _holdRepeatTimer = null;
+ }
+
+ void _triggerAction(GameAction action, double value) {
+ // Muted to prevent console clutter:
+ // debugPrint('🎮 Gamepad Action Triggered: $action (value: $value)');
+ // Broadcast the action to all listeners (screens, global handlers)
+ inputActionBus.add(action);
+
+ // Snappy navigation via direct focus movement
+ if (!_ref.read(navigationLockedProvider)) {
+ switch (action) {
+ case GameAction.up:
+ _moveFocus(TraversalDirection.up);
+ break;
+ case GameAction.down:
+ _moveFocus(TraversalDirection.down);
+ break;
+ case GameAction.left:
+ _moveFocus(TraversalDirection.left);
+ break;
+ case GameAction.right:
+ _moveFocus(TraversalDirection.right);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ void _moveFocus(TraversalDirection direction) {
+ final primary = FocusManager.instance.primaryFocus;
+ if (primary != null && primary.context != null) {
+ final renderObject = primary.context!.findRenderObject();
+ if (renderObject != null && renderObject.attached) {
+ primary.focusInDirection(direction);
+ }
+ }
+ }
+
+ void dispose() {
+ _subscription?.cancel();
+ _scanTimer?.cancel();
+ _cancelHoldTimers();
+ }
+}
diff --git a/lib/core/input/input_action_bus.dart b/lib/core/input/input_action_bus.dart
new file mode 100644
index 0000000..aaa494c
--- /dev/null
+++ b/lib/core/input/input_action_bus.dart
@@ -0,0 +1,7 @@
+import 'dart:async';
+import 'gamepad_service.dart';
+
+/// A global broadcast stream for controller and keyboard actions.
+/// Instead of simulating keyboard events, we push high-level actions
+/// that screens and widgets can listen to directly.
+final inputActionBus = StreamController<GameAction>.broadcast();
diff --git a/lib/core/input/known_controllers.dart b/lib/core/input/known_controllers.dart
new file mode 100644
index 0000000..6055b58
--- /dev/null
+++ b/lib/core/input/known_controllers.dart
@@ -0,0 +1,102 @@
+import 'gamepad_service.dart';
+
+/// Database of hardware-specific mappings for various controllers and modes.
+/// Following the SDL2 gamecontrollerdb pattern.
+const Map<String, Map<String, GameAction>> kControllerMappings = {
+ // 8BitDo Micro - Switch mode (macOS IOKit)
+ '8BitDo Micro': {
+ 'a.circle': GameAction.confirm,
+ 'b.circle': GameAction.back,
+ 'x.circle': GameAction.detail,
+ 'y.circle': GameAction.favorite,
+ 'l.joystick - yAxis': GameAction.verticalAxis,
+ 'l.joystick - xAxis': GameAction.horizontalAxis,
+ },
+
+ // 8BitDo Micro - D mode (Generic D-Input)
+ '8BitDo Micro gamepad': {
+ 'button_0': GameAction.confirm,
+ 'button_1': GameAction.back,
+ 'button_2': GameAction.detail,
+ 'button_3': GameAction.favorite,
+ 'dpad_up': GameAction.up,
+ 'dpad_down': GameAction.down,
+ 'dpad_left': GameAction.left,
+ 'dpad_right': GameAction.right,
+ },
+
+ // Xbox Controllers (Windows XInput / Generic Bluetooth)
+ 'Xbox': {
+ 'button_0': GameAction.confirm,
+ 'button_1': GameAction.back,
+ 'button_2': GameAction.detail,
+ 'button_3': GameAction.favorite,
+ 'a': GameAction.confirm,
+ 'b': GameAction.back,
+ 'x': GameAction.detail,
+ 'y': GameAction.favorite,
+ },
+
+ // DualSense / DualShock
+ 'DualSense': {
+ 'cross': GameAction.confirm,
+ 'circle': GameAction.back,
+ 'square': GameAction.detail,
+ 'triangle': GameAction.favorite,
+ },
+
+ // macOS Pro Controller / Switch-style Descriptive Strings
+ 'Pro Controller': {
+ 'a.circle': GameAction.confirm,
+ 'b.circle': GameAction.back,
+ 'x.circle': GameAction.detail,
+ 'y.circle': GameAction.favorite,
+ 'l.joystick - xAxis': GameAction.horizontalAxis,
+ 'l.joystick - yAxis': GameAction.verticalAxis,
+ 'dpad - xAxis': GameAction.horizontalAxis,
+ 'dpad - yAxis': GameAction.verticalAxis,
+ 'buttonMenu': GameAction.start,
+ 'buttonOptions': GameAction.select,
+ 'l.rectangle.roundedbottom': GameAction.l1,
+ 'r.rectangle.roundedbottom': GameAction.r1,
+ 'zl.rectangle.roundedtop': GameAction.l2,
+ 'zr.rectangle.roundedtop': GameAction.r2,
+ },
+};
+
+/// Fallback for unknown controllers - covers most XInput-style layouts
+const Map<String, GameAction> kDefaultMapping = {
+ 'button_0': GameAction.confirm,
+ 'button_1': GameAction.back,
+ 'button_2': GameAction.detail,
+ 'button_3': GameAction.favorite,
+ 'a': GameAction.confirm,
+ 'b': GameAction.back,
+ 'x': GameAction.detail,
+ 'y': GameAction.favorite,
+
+ // Mac/Switch Fallbacks
+ 'a.circle': GameAction.confirm,
+ 'b.circle': GameAction.back,
+ 'x.circle': GameAction.detail,
+ 'y.circle': GameAction.favorite,
+ 'l.joystick - xAxis': GameAction.horizontalAxis,
+ 'l.joystick - yAxis': GameAction.verticalAxis,
+ 'dpad - xAxis': GameAction.horizontalAxis,
+ 'dpad - yAxis': GameAction.verticalAxis,
+ 'buttonMenu': GameAction.start,
+ 'buttonOptions': GameAction.select,
+ 'l.rectangle.roundedbottom': GameAction.l1,
+ 'r.rectangle.roundedbottom': GameAction.r1,
+ 'zl.rectangle.roundedtop': GameAction.l2,
+ 'zr.rectangle.roundedtop': GameAction.r2,
+
+ 'dpad_up': GameAction.up,
+ 'dpad_down': GameAction.down,
+ 'dpad_left': GameAction.left,
+ 'dpad_right': GameAction.right,
+ 'button_11': GameAction.up,
+ 'button_12': GameAction.down,
+ 'button_13': GameAction.left,
+ 'button_14': GameAction.right,
+};
diff --git a/lib/core/input/sdl_parser.dart b/lib/core/input/sdl_parser.dart
new file mode 100644
index 0000000..a7edba7
--- /dev/null
+++ b/lib/core/input/sdl_parser.dart
@@ -0,0 +1,92 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'gamepad_service.dart';
+
+class SDLMappingParser {
+ static final Map<String, Map<String, GameAction>> _cache = {};
+
+ /// Loads and parses the SDL2 gamecontrollerdb.txt asset.
+ static Future<void> loadDatabase() async {
+ try {
+ final data = await rootBundle.loadString('thirdparty/gamecontrollerdb.txt');
+ final lines = data.split('\n');
+
+ for (var line in lines) {
+ if (line.startsWith('#') || line.trim().isEmpty) continue;
+
+ final parts = line.split(',');
+ if (parts.length < 3) continue;
+
+ final name = parts[1].trim();
+ final mapping = <String, GameAction>{};
+
+ for (var i = 2; i < parts.length; i++) {
+ final entry = parts[i].split(':');
+ if (entry.length != 2) continue;
+
+ final sdlKey = entry[0].trim();
+ final hardwareRef = entry[1].trim();
+
+ final action = _mapSDLKeyToAction(sdlKey);
+ if (action != null) {
+ // Translate SDL hardware ref (e.g. b0) to package key (e.g. button_0)
+ final packageKey = _translateToPackageKey(hardwareRef);
+ if (packageKey != null) {
+ mapping[packageKey] = action;
+ }
+ }
+ }
+ _cache[name.toLowerCase()] = mapping;
+ }
+ } catch (e) {
+ debugPrint('🎮 SDL Parser Error: $e');
+ }
+ }
+
+ static Map<String, GameAction>? getMapping(String name) {
+ final lower = name.toLowerCase();
+ // Try exact match or partial match
+ for (final entry in _cache.entries) {
+ if (lower.contains(entry.key)) {
+ return entry.value;
+ }
+ }
+ return null;
+ }
+
+ static GameAction? _mapSDLKeyToAction(String sdlKey) {
+ switch (sdlKey) {
+ case 'a': return GameAction.confirm;
+ case 'b': return GameAction.back;
+ case 'x': return GameAction.detail;
+ case 'y': return GameAction.favorite;
+ case 'dpup': return GameAction.up;
+ case 'dpdown': return GameAction.down;
+ case 'dpleft': return GameAction.left;
+ case 'dpright': return GameAction.right;
+ case 'leftx': return GameAction.horizontalAxis;
+ case 'lefty': return GameAction.verticalAxis;
+ default: return null;
+ }
+ }
+
+ static String? _translateToPackageKey(String hardwareRef) {
+ // SDL: b0, b1... -> Package: button_0, button_1...
+ if (hardwareRef.startsWith('b')) {
+ return 'button_${hardwareRef.substring(1)}';
+ }
+ // SDL: a0, a1... -> Package: l.joystick - xAxis, l.joystick - yAxis (Switch mode)
+ // or left_x, left_y (Generic)
+ // This is the tricky part - we'll map both to be safe
+ if (hardwareRef == 'a0') return 'left_x';
+ if (hardwareRef == 'a1') return 'left_y';
+ if (hardwareRef == 'a2') return 'right_x';
+ if (hardwareRef == 'a3') return 'right_y';
+
+ // Fallback for Switch-style names seen in logs
+ if (hardwareRef == 'a0') return 'l.joystick - xAxis';
+ if (hardwareRef == 'a1') return 'l.joystick - yAxis';
+
+ return null;
+ }
+}
diff --git a/lib/core/romm/rom_constants.dart b/lib/core/romm/rom_constants.dart
new file mode 100644
index 0000000..755b09f
--- /dev/null
+++ b/lib/core/romm/rom_constants.dart
@@ -0,0 +1,37 @@
+import 'package:path/path.dart' as p;
+
+class RomConstants {
+ // Known extensions per platform slug
+ static const Map<String, List<String>> platformExtensions = {
+ 'switch': ['.nsp', '.xci', '.nsz', '.xcz'],
+ 'nintendo-switch': ['.nsp', '.xci', '.nsz', '.xcz'],
+ 'ns': ['.nsp', '.xci', '.nsz', '.xcz'],
+ 'gba': ['.gba', '.zip', '.7z'],
+ 'gbc': ['.gbc', '.gb', '.zip', '.7z'],
+ 'gb': ['.gb', '.gbc', '.zip', '.7z'],
+ 'nds': ['.nds', '.zip', '.7z'],
+ 'n64': ['.z64', '.n64', '.v64', '.zip', '.7z'],
+ 'snes': ['.sfc', '.smc', '.zip', '.7z'],
+ 'nes': ['.nes', '.zip', '.7z'],
+ 'psx': ['.bin', '.cue', '.iso', '.img', '.chd', '.pbp'],
+ 'ps2': ['.iso', '.bin', '.chd'],
+ 'ps3': ['.pkg', '.iso', '.bin', '.edat'],
+ 'psp': ['.iso', '.cso', '.pbp'],
+ 'gc': ['.iso', '.gcm', '.rvz', '.wbfs'],
+ 'gamecube': ['.iso', '.gcm', '.rvz', '.wbfs'],
+ 'wii': ['.iso', '.wbfs', '.rvz'],
+ 'dreamcast': ['.chd', '.gdi', '.cdi', '.iso'],
+ 'megadrive': ['.md', '.bin', '.gen', '.zip', '.7z'],
+ 'genesis': ['.md', '.bin', '.gen', '.zip', '.7z'],
+ '3ds': ['.3ds', '.cia', '.app'],
+ 'wiiu': ['.wua', '.rpx', '.wud', '.wux'],
+ };
+
+ static bool isRomFile(String platformSlug, String path) {
+ final ext = p.extension(path).toLowerCase();
+ if (ext.isEmpty) return false;
+ final allowed = platformExtensions[platformSlug.toLowerCase()] ?? [];
+ if (allowed.isEmpty) return true; // If no list, allow all (risky but fallback)
+ return allowed.contains(ext);
+ }
+}
diff --git a/lib/core/romm/rom_scanner_service.dart b/lib/core/romm/rom_scanner_service.dart
index 1142a81..3935613 100644
--- a/lib/core/romm/rom_scanner_service.dart
+++ b/lib/core/romm/rom_scanner_service.dart
@@ -6,6 +6,7 @@ import '../storage/rom_mapping_service.dart';
import 'romm_models.dart';
import 'package:crypto/crypto.dart';
import '../storage/directory_service.dart';
+import '../storage/file_system_index.dart';
class RomSyncResult {
final String path;
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index 2d36b4d..744d379 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -18,7 +18,7 @@ class RommService {
RomMConfig get config => _config;
- static const String _ua = 'Freegosy/0.3.2';
+ static const String _ua = 'Freegosy/0.5.0';
void updateConfig(RomMConfig newConfig) {
_config = newConfig;
diff --git a/lib/core/save/save_strategy.dart b/lib/core/save/save_strategy.dart
index 5df4003..b2144e4 100644
--- a/lib/core/save/save_strategy.dart
+++ b/lib/core/save/save_strategy.dart
@@ -1,7 +1,8 @@
-import 'dart:io';
+import 'dart:io' as io;
import 'dart:typed_data';
import 'package:path/path.dart' as p;
+import '../storage/directory_service.dart';
import '../romm/romm_models.dart';
// ─── Exceptions ──────────────────────────────────────────────────────────────
@@ -16,20 +17,25 @@ class SaveMappingRequiredException implements Exception {
/// Abstract base for all save-file strategies.
abstract class SaveStrategy {
String get strategyId;
-
+
/// Whether this strategy supports save synchronization.
bool get supportsSaveSync => false;
+ /// Whether this strategy prefers to upload/download saves as zipped archives.
+ /// Strategies that return false will upload the raw save file directly,
+ /// which is needed for emulators like emulator.js in RomM to read the files (e.g. .srm, .sav).
+ bool get shouldZip => true;
+
/// Returns the local save directory for [game] given its [romPath].
Future<String?> getSaveDir(Game game, String romPath);
/// Returns all save files associated with [game].
/// If [sessionStart] is provided, only files modified after that time are returned.
- Future<List<File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'});
+ Future<List<io.File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'});
/// Returns all save files associated with [game], optionally paired with screenshots.
/// If [sessionStart] is provided, only files modified after that time are returned.
- Future<Map<File, File?>> getSaveFilesWithScreenshots(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
+ Future<Map<io.File, io.File?>> getSaveFilesWithScreenshots(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
final files = await getSaveFiles(game, romPath, sessionStart: sessionStart, syncMode: syncMode);
return {for (var f in files) f: null};
}
@@ -42,12 +48,12 @@ abstract class SaveStrategy {
/// Creates a .bak rotation (up to 3 versions) for the file at [path].
Future<void> backupSave(String path) async {
final normalized = p.normalize(path);
- final file = File(normalized);
+ final file = io.File(normalized);
if (!await file.exists()) return;
try {
- final bak2 = File('$normalized.bak2');
- final bak1 = File('$normalized.bak1');
- final bak = File('$normalized.bak');
+ final bak2 = io.File('$normalized.bak2');
+ final bak1 = io.File('$normalized.bak1');
+ final bak = io.File('$normalized.bak');
if (await bak2.exists()) await bak2.delete();
if (await bak1.exists()) await bak1.rename('$normalized.bak2');
if (await bak.exists()) await bak.rename('$normalized.bak1');
@@ -66,4 +72,68 @@ abstract class SaveStrategy {
if (dot > 0) return name.substring(0, dot);
return name;
}
+
+ // ─── Shared helper: RetroArch config lookup ──────────────────────────────
+
+ /// Reads `savefile_directory` from retroarch.cfg for platforms where RetroArch
+ /// manages saves via a core subfolder (e.g. mGBA, MelonDS, Dolphin).
+ ///
+ /// Returns the full path to the core-specific subfolder (e.g.
+ /// `/Users/xyz/Documents/RetroArch/saves/mGBA`), or `null` if the config
+ /// is not found or the core folder does not exist.
+ static Future<String?> retroarchCoreSaveDir(DirectoryService directoryService, String coreSaveFolder) async {
+ final List<String> configCandidates = [];
+
+ if (io.Platform.isMacOS) {
+ final home = io.Platform.environment['HOME'] ?? '';
+ configCandidates.add(p.join(home, 'Library', 'Application Support', 'RetroArch', 'config', 'retroarch.cfg'));
+ configCandidates.add(p.join(home, '.config', 'retroarch', 'retroarch.cfg'));
+ } else if (io.Platform.isLinux) {
+ final home = io.Platform.environment['HOME'] ?? '';
+ configCandidates.add(p.join(home, '.config', 'retroarch', 'retroarch.cfg'));
+ } else if (io.Platform.isWindows) {
+ final appData = io.Platform.environment['APPDATA'] ?? '';
+ configCandidates.add(p.join(appData, 'RetroArch', 'retroarch.cfg'));
+ }
+
+ final exePath = await directoryService.findEmulatorExecutable('retroarch', _retroarchExe());
+ if (exePath != null) {
+ String exeDir = io.Platform.isMacOS
+ ? p.join(io.File(exePath).parent.parent.parent.parent.path)
+ : io.File(exePath).parent.path;
+ if (await io.FileSystemEntity.isDirectory(exePath)) exeDir = exePath;
+ configCandidates.add(p.join(exeDir, 'retroarch.cfg'));
+ }
+
+ for (final cfgPath in configCandidates) {
+ final cfgFile = io.File(cfgPath);
+ if (!await cfgFile.exists()) continue;
+ try {
+ final lines = await cfgFile.readAsLines();
+ for (final line in lines) {
+ final match = RegExp(r'^\s*savefile_directory\s*=\s*"([^"]*)"').firstMatch(line);
+ if (match != null) {
+ var dir = match.group(1)!;
+ if (dir.startsWith('~')) {
+ final home = io.Platform.environment['HOME'];
+ if (home != null) dir = dir.replaceFirst('~', home);
+ }
+ if (await io.Directory(dir).exists()) {
+ final coreDir = p.join(dir, coreSaveFolder);
+ if (await io.Directory(coreDir).exists()) {
+ return coreDir;
+ }
+ }
+ }
+ }
+ } catch (_) {}
+ }
+ return null;
+ }
+
+ static String _retroarchExe() {
+ if (io.Platform.isWindows) return 'RetroArch.exe';
+ if (io.Platform.isMacOS) return 'RetroArch.app/Contents/MacOS/RetroArch';
+ return 'retroarch';
+ }
}
diff --git a/lib/core/save/save_sync_service.dart b/lib/core/save/save_sync_service.dart
index 866eb64..ad54f79 100644
--- a/lib/core/save/save_sync_service.dart
+++ b/lib/core/save/save_sync_service.dart
@@ -144,13 +144,13 @@ class SaveSyncService {
case 'nintendo-ds':
case 'ds':
return _melonds;
- case 'psx':
- case 'ps1':
- case 'playstation':
- return _duckstation;
case 'psp':
case 'playstation-portable':
return _ppsspp;
+ case 'ps1':
+ case 'playstation':
+ case 'psx':
+ return _duckstation;
case 'dc':
case 'dreamcast':
return _retroarch;
@@ -260,13 +260,37 @@ class SaveSyncService {
strategy.setManualMapping(mapping);
}
- final filesMap = await strategy.getSaveFilesWithScreenshots(
+ var filesMap = await strategy.getSaveFilesWithScreenshots(
game, romPath,
sessionStart: sessionStart,
syncMode: syncMode,
);
if (filesMap.isEmpty) return false;
+ // If the strategy does not support zipping, filter filesMap to only keep the primary save file
+ // (typically ending in .srm, .sav, or .gci) to ensure it is uploaded raw/unzipped.
+ if (!strategy.shouldZip) {
+ final filteredMap = <io.File, io.File?>{};
+ for (final entry in filesMap.entries) {
+ final pathLower = entry.key.path.toLowerCase();
+ if (pathLower.endsWith('.srm') || pathLower.endsWith('.sav') || pathLower.endsWith('.gci')) {
+ filteredMap[entry.key] = entry.value;
+ break; // Keep only the first primary save file
+ }
+ }
+ // Fallback if no specific extension matches: keep the first file entry if it's not a directory
+ if (filteredMap.isEmpty) {
+ for (final entry in filesMap.entries) {
+ if (!await io.FileSystemEntity.isDirectory(entry.key.path)) {
+ filteredMap[entry.key] = entry.value;
+ break;
+ }
+ }
+ }
+ filesMap = filteredMap;
+ }
+ if (filesMap.isEmpty) return false;
+
// --- Conflict Detection ---
if (!force) {
final latestRemote = await _rommService.getLatestSave(game.id);
@@ -302,53 +326,73 @@ class SaveSyncService {
await io.Directory(tempDir).create(recursive: true);
}
- // --- Prepare unique bundle ZIP to bypass server-side deduplication ---
- final bundleZipPath = p.join(tempDir, '$displayStem.bundle.${DateTime.now().millisecondsSinceEpoch}.zip');
- final encoder = ZipFileEncoder();
- encoder.create(bundleZipPath);
-
- // 1. Write fresh sync metadata
- final metaFile = io.File(p.join(tempDir, 'freegosy_sync.txt'));
- await metaFile.writeAsString(DateTime.now().toIso8601String());
- await encoder.addFile(metaFile);
-
- // 2. Add all files/folders from the map
- for (final entry in filesMap.entries) {
- final file = entry.key;
- if (await io.FileSystemEntity.isDirectory(file.path)) {
- await encoder.addDirectory(io.Directory(file.path), includeDirName: true);
- } else {
- await encoder.addFile(file, p.basename(file.path));
+ io.File? finalUploadFile;
+ io.File? finalScreenshotFile;
+ String uploadFilename;
+ bool isBundle = false;
+
+ // Decide whether to bundle (zip) or upload directly
+ // We bundle if there are multiple files, or if the single entry is a directory
+ if (filesMap.length == 1 && !await io.FileSystemEntity.isDirectory(filesMap.keys.first.path)) {
+ final entry = filesMap.entries.first;
+ finalUploadFile = entry.key;
+ finalScreenshotFile = entry.value;
+ uploadFilename = p.basename(finalUploadFile.path);
+ debugPrint('[Sync] Uploading single save file directly: $uploadFilename');
+ } else {
+ isBundle = true;
+ // --- Prepare unique bundle ZIP to bypass server-side deduplication ---
+ final bundleZipPath = p.join(tempDir, '$displayStem.bundle.${DateTime.now().millisecondsSinceEpoch}.zip');
+ final encoder = ZipFileEncoder();
+ encoder.create(bundleZipPath);
+
+ // 1. Write sync metadata (only for bundles to help with multi-file coherence)
+ final metaFile = io.File(p.join(tempDir, 'freegosy_sync.txt'));
+ await metaFile.writeAsString(DateTime.now().toIso8601String());
+ await encoder.addFile(metaFile);
+
+ // 2. Add all files/folders from the map
+ for (final entry in filesMap.entries) {
+ final file = entry.key;
+ if (await io.FileSystemEntity.isDirectory(file.path)) {
+ await encoder.addDirectory(io.Directory(file.path), includeDirName: true);
+ } else {
+ await encoder.addFile(file, p.basename(file.path));
+ }
}
+ encoder.close();
+
+ finalUploadFile = io.File(bundleZipPath);
+ uploadFilename = '$displayStem.zip';
+ finalScreenshotFile = filesMap.values.firstWhere((s) => s != null, orElse: () => null);
+ debugPrint('[Sync] Uploading bundled save: $uploadFilename');
}
- encoder.close();
- final uploadFile = io.File(bundleZipPath);
- final String localHash = await _hashFile(uploadFile);
- final String uploadFilename = '$displayStem.zip';
+ final String localHash = await _hashFile(finalUploadFile);
final String? storedHash = _getStoredHash(game.id, uploadFilename);
// Local deduplication check (only for automatic syncs)
if (!force && storedHash != null && localHash == storedHash) {
debugPrint('[Sync] Skipping upload for $displayStem: hash matches local cache ($localHash)');
- if (await uploadFile.exists()) await uploadFile.delete();
+ if (isBundle && await finalUploadFile.exists()) await finalUploadFile.delete();
return true;
}
final ok = await _rommService.uploadSave(
game.id,
- uploadFile,
- screenshotFile: filesMap.values.firstWhere((s) => s != null, orElse: () => null),
+ finalUploadFile,
+ screenshotFile: finalScreenshotFile,
overrideFilename: uploadFilename,
);
if (ok) {
uploaded++;
await _storeHash(game.id, uploadFilename, localHash);
- debugPrint('[Sync] Successfully pushed save bundle for $displayStem (forced: $force)');
+ debugPrint('[Sync] Successfully pushed save for $displayStem (forced: $force)');
}
- if (await uploadFile.exists()) await uploadFile.delete();
+ if (isBundle && await finalUploadFile.exists()) await finalUploadFile.delete();
+ final metaFile = io.File(p.join(tempDir, 'freegosy_sync.txt'));
if (await metaFile.exists()) await metaFile.delete();
if (uploaded > 0) {
@@ -368,6 +412,26 @@ class SaveSyncService {
return _rommService.getSavesList(gameId);
}
+ /// Checks if [data] begins with ZIP magic bytes (PK\x03\x04).
+ bool _isZipBytes(Uint8List data) =>
+ data.length >= 4 &&
+ data[0] == 0x50 &&
+ data[1] == 0x4B &&
+ data[2] == 0x03 &&
+ data[3] == 0x04;
+
+ /// Ensures the filename matches the actual content format. If [data] is a
+ /// ZIP but [filename] doesn't end with .zip, appends .zip so strategies
+ /// can correctly extract the save inside. This makes manually-uploaded ZIPs
+ /// and any ZIP whose cloud filename lacks the extension work seamlessly.
+ String _adjustFilenameForFormat(Uint8List data, String filename) {
+ if (filename.toLowerCase().endsWith('.zip')) return filename;
+ if (_isZipBytes(data)) {
+ return '${p.basenameWithoutExtension(filename)}.zip';
+ }
+ return filename;
+ }
+
/// Downloads a specific save for [game] from RomM and restores it locally.
Future<bool> pullSave(Game game, String romPath, {Map<String, dynamic>? saveData}) async {
try {
@@ -415,8 +479,12 @@ class SaveSyncService {
final filename = save['file_name'] as String?
?? downloadUrl.split('/').last;
+ // Sniff actual bytes so that ZIP files (even those manually uploaded or
+ // stored under a non-.zip name) are correctly extracted on restore.
+ final adjustedFilename = _adjustFilenameForFormat(bytes, filename);
+
final ok = await strategy.restoreSave(
- game, romPath, bytes, filename);
+ game, romPath, bytes, adjustedFilename);
if (ok) {
await _setLastPullTime(game.id);
diff --git a/lib/core/save/strategies/dolphin_save_strategy.dart b/lib/core/save/strategies/dolphin_save_strategy.dart
index 8b94419..fdca903 100644
--- a/lib/core/save/strategies/dolphin_save_strategy.dart
+++ b/lib/core/save/strategies/dolphin_save_strategy.dart
@@ -3,6 +3,7 @@ import 'dart:io' as io;
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
+import 'package:archive/archive_io.dart';
import '../../romm/romm_models.dart';
import '../../storage/directory_service.dart';
@@ -20,6 +21,9 @@ class DolphinSaveStrategy extends SaveStrategy {
@override
String get strategyId => 'dolphin';
+ @override
+ bool get shouldZip => false;
+
String _getEmuExe() {
if (io.Platform.isWindows) return 'Dolphin.exe';
if (io.Platform.isMacOS) return 'Dolphin.app/Contents/MacOS/Dolphin';
@@ -282,6 +286,20 @@ class DolphinSaveStrategy extends SaveStrategy {
final dir = io.Directory(saveDir);
if (!await dir.exists()) await dir.create(recursive: true);
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final file in archive) {
+ if (!file.isFile) continue;
+ if (file.name == 'freegosy_sync.txt') continue;
+
+ final targetPath = p.normalize(p.join(saveDir, file.name));
+ await io.Directory(p.dirname(targetPath)).create(recursive: true);
+ await backupSave(targetPath);
+ await io.File(targetPath).writeAsBytes(file.content);
+ }
+ return true;
+ }
+
final targetPath = p.normalize(p.join(saveDir, filename));
await backupSave(targetPath);
await io.File(targetPath).writeAsBytes(data);
diff --git a/lib/core/save/strategies/eden_save_strategy.dart b/lib/core/save/strategies/eden_save_strategy.dart
index 8e87f2c..0aff3b5 100644
--- a/lib/core/save/strategies/eden_save_strategy.dart
+++ b/lib/core/save/strategies/eden_save_strategy.dart
@@ -4,715 +4,240 @@ import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../../romm/romm_models.dart';
import '../../storage/directory_service.dart';
+import '../../storage/rom_lookup_service.dart';
import '../save_strategy.dart';
-// ─── Exceptions ──────────────────────────────────────────────────────────────
-
class ProfileConflictException implements Exception {
final List<Map<String, dynamic>> profiles;
ProfileConflictException(this.profiles);
@override
- String toString() =>
- 'ProfileConflictException: ${profiles.length} active profiles found.';
+ String toString() => 'ProfileConflictException: ${profiles.length} active profiles found.';
}
-// ─── Strategy ────────────────────────────────────────────────────────────────
-
/// Save strategy for Eden (Switch) emulator.
-///
-/// Resolution order for Title ID:
-/// 1. ROM header byte scan (.cnmt string in first 256 KB)
-/// 2. Title ID extracted from ROM filename e.g. [0100704000B3A000]
-/// 3. Manual mapping stored in SharedPreferences
-/// 4. Throw [SaveMappingRequiredException] → UI shows folder picker
-///
-/// Resolution order for Profile ID:
-/// 1. Manual override (user picked from conflict dialog)
-/// 2. Recency heuristic on actual save *files* (not dir mtime)
-/// 3. Throw [ProfileConflictException] → UI shows profile picker
class EdenSaveStrategy extends SaveStrategy {
final DirectoryService _directoryService;
- final Future<void> Function(String gameId, String titleId)?
- onMappingResolved;
+ final Future<void> Function(String gameId, String titleId)? onMappingResolved;
EdenSaveStrategy(this._directoryService, {this.onMappingResolved});
@override
String get strategyId => 'switch';
-
@override
bool get supportsSaveSync => true;
- // ── Regex library ──────────────────────────────────────────────────────────
-
- /// Matches a valid 16-char Switch Title ID anchored to full string.
- static final RegExp _strictTitleIdRegex =
- RegExp(r'^01[0-9A-Fa-f]{14}$');
-
- /// Extracts a 16-char Title ID anywhere in a string.
- static final RegExp _titleIdExtractorRegex =
- RegExp(r'01[0-9A-Fa-f]{14}');
-
- /// Finds `<TitleID>.cnmt` in raw byte content (the header scanner).
- static final RegExp _cnmtRegex =
- RegExp(r'(01[0-9A-Fa-f]{14})\.cnmt', caseSensitive: false);
-
- /// Matches a 32-char hex string (Eden profile folder name).
+ static final RegExp _titleIdExtractorRegex = RegExp(r'01[0-9A-Fa-f]{14}');
+ static final RegExp _cnmtRegex = RegExp(r'(01[0-9A-Fa-f]{14})\.cnmt', caseSensitive: false);
static final RegExp _profileRegex = RegExp(r'^[0-9A-Fa-f]{32}$');
-
- /// ROM file extensions we care about.
static const _romExtensions = ['.nsp', '.xci', '.nsz'];
- // ── Mutable state (set per-sync from SaveSyncService) ──────────────────────
-
String? _manualMapping;
String? _activeProfileOverride;
void setManualMapping(String? titleId) => _manualMapping = titleId;
- void setActiveProfileOverride(String? profileId) =>
- _activeProfileOverride = profileId;
+ void setActiveProfileOverride(String? profileId) => _activeProfileOverride = profileId;
- // ═══════════════════════════════════════════════════════════════════════════
- // LAYER 1 — Title ID Resolution
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Master resolver: header → filename → manual mapping → throw.
Future<String> _resolveTitleId(String romPath, Game game) async {
- // 1. Header byte scan (the source of truth)
- final (fromHeader, resolvedRomPath) =
- await extractTitleIdFromHeader(romPath);
+ final (fromHeader, resolvedRomPath) = await extractTitleIdFromHeader(romPath);
if (fromHeader != null) {
- debugPrint('[Eden] Title ID from header: $fromHeader');
- // Persist so future syncs skip the scan
- if (onMappingResolved != null) {
- await onMappingResolved!(game.id, fromHeader);
- }
+ if (onMappingResolved != null) await onMappingResolved!(game.id, fromHeader);
return fromHeader;
}
- // 2. Extract from ROM filename e.g. "Splatoon 3 [0100C2500FC20000][v0].nsp"
- final fromFilename =
- _extractTitleIdFromFilename(resolvedRomPath ?? romPath, game);
+ final fromFilename = _extractTitleIdFromFilename(resolvedRomPath ?? romPath, game);
if (fromFilename != null) {
- debugPrint('[Eden] Title ID from filename: $fromFilename');
- if (onMappingResolved != null) {
- await onMappingResolved!(game.id, fromFilename);
- }
+ if (onMappingResolved != null) await onMappingResolved!(game.id, fromFilename);
return fromFilename;
}
- // 3. Manual mapping (SharedPreferences, set by SaveSyncService)
- if (_manualMapping != null && _manualMapping!.isNotEmpty) {
- debugPrint('[Eden] Title ID from manual mapping: $_manualMapping');
- return _manualMapping!;
- }
+ if (_manualMapping != null && _manualMapping!.isNotEmpty) return _manualMapping!;
- // 4. Nothing worked
- debugPrint('[Eden] FAILED: No Title ID resolved for ${game.name}');
- throw SaveMappingRequiredException(
- 'Could not determine Title ID for "${game.name}". '
- 'Please select the save folder manually.');
+ throw SaveMappingRequiredException('Could not determine Title ID for "${game.name}". Please select the save folder manually.');
}
- /// Tries to pull a Title ID from the ROM's filename or the Game model name.
String? _extractTitleIdFromFilename(String romPath, Game game) {
- // Try the actual file path first, then game.fileName, then game.name
- for (final candidate in [
- p.basename(romPath),
- game.fileName ?? '',
- game.name,
- ]) {
+ for (final candidate in [p.basename(romPath), game.fileName ?? '', game.name]) {
if (candidate.isEmpty) continue;
final match = _titleIdExtractorRegex.firstMatch(candidate);
- if (match != null) {
- return _normalizeToBaseId(match.group(0)!);
- }
+ if (match != null) return _normalizeToBaseId(match.group(0)!);
}
return null;
}
- /// Scans the first 256 KB of a ROM for `<TitleID>.cnmt` strings.
- ///
- /// If [romPath] doesn't point to an actual file, this method:
- /// 1. Tries appending .nsp / .xci / .nsz
- /// 2. Fuzzy-searches the parent directory for a matching ROM
- /// 3. If the path is a directory, picks the largest ROM inside
- Future<(String? titleId, String? resolvedPath)> extractTitleIdFromHeader(
- String romPath) async {
- final actualPath = await _resolveRomFile(romPath);
- if (actualPath == null) {
- debugPrint('[Eden][Scanner] No ROM file found for: $romPath');
- return (null, null);
- }
-
- debugPrint('[Eden][Scanner] Reading: $actualPath');
+ Future<(String? titleId, String? resolvedPath)> extractTitleIdFromHeader(String romPath) async {
+ final actualPath = await RomLookupService.resolveFuzzyRomFile(romPath, _romExtensions);
+ if (actualPath == null) return (null, null);
try {
final file = io.File(actualPath);
final raf = await file.open();
final bytes = await raf.read(262144); // 256 KB
await raf.close();
- debugPrint('[Eden][Scanner] Read ${bytes.length} bytes');
-
- // Search for <16-hex>.cnmt pattern in the raw bytes
final content = String.fromCharCodes(bytes);
final match = _cnmtRegex.firstMatch(content);
-
- if (match != null) {
- final raw = match.group(1)!;
- final normalized = _normalizeToBaseId(raw);
- debugPrint('[Eden][Scanner] Found: ${match.group(0)} → $normalized');
- return (normalized, actualPath);
- }
-
- debugPrint('[Eden][Scanner] No .cnmt match in first 256 KB');
- } catch (e) {
- debugPrint('[Eden][Scanner] ERROR: $e');
- }
+ if (match != null) return (_normalizeToBaseId(match.group(1)!), actualPath);
+ } catch (_) {}
return (null, actualPath);
}
- /// Force the last 3 hex chars to "000" → base game save folder.
- /// e.g. 0100704000B3A800 → 0100704000B3A000
static String _normalizeToBaseId(String raw) {
final upper = raw.toUpperCase();
- if (upper.length == 16) {
- return '${upper.substring(0, 13)}000';
- }
- return upper;
+ return upper.length == 16 ? '${upper.substring(0, 13)}000' : upper;
}
- // ═══════════════════════════════════════════════════════════════════════════
- // LAYER 2 — Profile ID Resolution
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Discovers the active Eden profile folder.
- ///
- /// Validation criteria: a profile is "active" only if it contains at least
- /// one Title ID subfolder that itself contains actual save data files
- /// (e.g. save.bin). This eliminates ghost/empty profiles.
Future<String> _resolveProfileId(String baseSavePath) async {
- // 1. Manual override from conflict dialog
- if (_activeProfileOverride != null &&
- _activeProfileOverride!.isNotEmpty) {
- debugPrint('[Eden][Profile] Using override: $_activeProfileOverride');
- return _activeProfileOverride!;
- }
-
- // 2. Scan all 32-hex folders under .../save/0000000000000000/
- final zeroDir =
- io.Directory(p.join(baseSavePath, '0000000000000000'));
- if (!zeroDir.existsSync()) {
- throw Exception(
- 'Eden save base does not exist: ${zeroDir.path}');
- }
+ if (_activeProfileOverride != null && _activeProfileOverride!.isNotEmpty) return _activeProfileOverride!;
+ final zeroDir = io.Directory(p.join(baseSavePath, '0000000000000000'));
+ if (!zeroDir.existsSync()) throw Exception('Eden save base does not exist: ${zeroDir.path}');
final candidates = <Map<String, dynamic>>[];
-
for (final entity in zeroDir.listSync()) {
if (entity is! io.Directory) continue;
final name = p.basename(entity.path);
if (!_profileRegex.hasMatch(name)) continue;
- // Find the newest *file* timestamp inside any Title ID subfolder.
- // Critical: we check FILE mtime, not directory mtime.
- // This kills the ghost profile bug.
DateTime? newestFileTime;
int saveFileCount = 0;
-
for (final titleDir in entity.listSync()) {
if (titleDir is! io.Directory) continue;
- final titleName = p.basename(titleDir.path);
- if (!titleName.startsWith('01') || titleName.length != 16) continue;
-
try {
for (final file in titleDir.listSync(recursive: true)) {
- if (file is! io.File) continue;
- final fname = p.basename(file.path);
- if (fname.startsWith('.') || fname.endsWith('.bak')) continue;
-
+ if (file is! io.File || p.basename(file.path).startsWith('.') || p.basename(file.path).endsWith('.bak')) continue;
saveFileCount++;
- final stat = file.statSync();
- if (newestFileTime == null ||
- stat.modified.isAfter(newestFileTime)) {
- newestFileTime = stat.modified;
- }
+ final mtime = file.statSync().modified;
+ if (newestFileTime == null || mtime.isAfter(newestFileTime)) newestFileTime = mtime;
}
- } catch (_) {
- // Permission errors, broken symlinks — skip
- }
+ } catch (_) {}
}
-
- // Only consider profiles that actually contain save files
- if (newestFileTime != null && saveFileCount > 0) {
- candidates.add({
- 'id': name,
- 'newestFile': newestFileTime,
- 'fileCount': saveFileCount,
- });
- }
- }
-
- if (candidates.isEmpty) {
- throw Exception(
- 'No active Eden profiles found in ${zeroDir.path}. '
- 'Have you played any games in Eden yet?');
+ if (newestFileTime != null && saveFileCount > 0) candidates.add({'id': name, 'newestFile': newestFileTime, 'fileCount': saveFileCount});
}
- // Sort by newest save file activity
- candidates.sort((a, b) => (b['newestFile'] as DateTime)
- .compareTo(a['newestFile'] as DateTime));
+ if (candidates.isEmpty) throw Exception('No active Eden profiles found.');
+ candidates.sort((a, b) => (b['newestFile'] as DateTime).compareTo(a['newestFile'] as DateTime));
+ if (candidates.length == 1) return candidates.first['id'] as String;
- // Single candidate → done
- if (candidates.length == 1) {
- final id = candidates.first['id'] as String;
- debugPrint('[Eden][Profile] Single active profile: $id');
- return id;
- }
-
- // Multiple candidates: check if the winner is clearly ahead
final winner = candidates[0];
final runnerUp = candidates[1];
- final gap = (winner['newestFile'] as DateTime)
- .difference(runnerUp['newestFile'] as DateTime);
-
- // Auto-pick if >1h newer or runner-up has very few files (ghost)
- if (gap.inHours >= 1 ||
- (runnerUp['fileCount'] as int) <= 1) {
- final id = winner['id'] as String;
- debugPrint(
- '[Eden][Profile] Auto-selected: $id '
- '(gap: ${gap.inMinutes}m, '
- 'runner-up files: ${runnerUp['fileCount']})');
- return id;
- }
-
- // Genuine conflict
- debugPrint(
- '[Eden][Profile] CONFLICT: ${candidates.map((c) => c['id']).join(', ')}');
+ final gap = (winner['newestFile'] as DateTime).difference(runnerUp['newestFile'] as DateTime);
+ if (gap.inHours >= 1 || (runnerUp['fileCount'] as int) <= 1) return winner['id'] as String;
throw ProfileConflictException(candidates);
}
- // ═══════════════════════════════════════════════════════════════════════════
- // ROM file resolution (fuzzy matching)
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Given a path that might be missing an extension, might be a directory,
- /// or might have a slightly different name than the actual file on disk,
- /// find the real ROM file.
- Future<String?> _resolveRomFile(String romPath) async {
- // Case 1: Exact path exists and is a file
- if (io.File(romPath).existsSync()) return romPath;
-
- // Case 2: Try appending known extensions
- for (final ext in _romExtensions) {
- final withExt = '$romPath$ext';
- if (io.File(withExt).existsSync()) {
- debugPrint('[Eden][Resolve] Extension fix: $withExt');
- return withExt;
- }
- }
-
- // Case 3: Path is a directory → find largest ROM inside
- if (io.Directory(romPath).existsSync()) {
- return _findLargestRomInDir(romPath);
- }
-
- // Case 4: Fuzzy search the parent directory
- final parentPath = p.dirname(romPath);
- final parentDir = io.Directory(parentPath);
- if (!parentDir.existsSync()) return null;
-
- final baseName = p.basenameWithoutExtension(romPath);
- final searchTokens = _tokenize(baseName);
-
- if (searchTokens.isEmpty) return null;
-
- io.File? bestMatch;
- int bestScore = 0;
-
- for (final entity in parentDir.listSync()) {
- if (entity is! io.File) continue;
- final fileName = p.basename(entity.path).toLowerCase();
- if (!_romExtensions.any((ext) => fileName.endsWith(ext))) continue;
-
- final fileTokens = _tokenize(p.basenameWithoutExtension(entity.path));
- int score = 0;
- for (final token in searchTokens) {
- if (fileTokens.contains(token)) score++;
- }
-
- // Require at least half the search tokens to match
- if (score > bestScore && score >= (searchTokens.length / 2).ceil()) {
- bestScore = score;
- bestMatch = entity;
- }
- }
-
- if (bestMatch != null) {
- debugPrint(
- '[Eden][Resolve] Fuzzy match: ${bestMatch.path} '
- '(score: $bestScore/${searchTokens.length})');
- return bestMatch.path;
- }
-
- // Case 4b: Fuzzy match DIRECTORIES in parent (multi-file ROMs)
- // e.g. path is ".../switch/Snipperclips Cut it out Together" but actual
- // dir is ".../switch/Snipperclips_ Cut It Out, Together!"
- for (final entity in parentDir.listSync()) {
- if (entity is! io.Directory) continue;
- final dirName = p.basename(entity.path);
- final dirTokens = _tokenize(dirName);
- int score = 0;
- for (final token in searchTokens) {
- if (dirTokens.contains(token)) score++;
- }
- if (score >= (searchTokens.length / 2).ceil() && score > 0) {
- final found = _findLargestRomInDir(entity.path);
- if (found != null) {
- debugPrint('[Eden][Resolve] Fuzzy dir match: ${entity.path} → $found');
- return found;
- }
- }
- }
-
- // Case 5: Try one level up (multi-file ROMs in sibling dirs)
- final grandparent = io.Directory(p.dirname(parentPath));
- if (grandparent.existsSync()) {
- for (final subDir in grandparent.listSync()) {
- if (subDir is! io.Directory) continue;
- final dirName = p.basename(subDir.path).toLowerCase();
- final dirTokens = _tokenize(dirName);
- int score = 0;
- for (final token in searchTokens) {
- if (dirTokens.contains(token)) score++;
- }
- if (score >= (searchTokens.length / 2).ceil()) {
- final found = _findLargestRomInDir(subDir.path);
- if (found != null) {
- debugPrint('[Eden][Resolve] Found in sibling dir: $found');
- return found;
- }
- }
- }
- }
-
- return null;
- }
-
- /// Find the largest .nsp/.xci/.nsz file in a directory tree.
- String? _findLargestRomInDir(String dirPath) {
- io.File? largest;
- int maxSize = -1;
-
- try {
- for (final entity
- in io.Directory(dirPath).listSync(recursive: true)) {
- if (entity is! io.File) continue;
- final name = entity.path.toLowerCase();
- if (!_romExtensions.any((ext) => name.endsWith(ext))) continue;
-
- final size = entity.lengthSync();
- if (size > maxSize) {
- maxSize = size;
- largest = entity;
- }
- }
- } catch (_) {}
-
- return largest?.path;
- }
-
- /// Splits a filename into lowercase tokens for fuzzy comparison.
- /// "Snipperclips - Plus [0100704000B3A000]" → {snipperclips, plus}
- static Set<String> _tokenize(String input) {
- return input
- .toLowerCase()
- .replaceAll(RegExp(r'\[[^\]]*\]'), '') // strip [brackets]
- .replaceAll(RegExp(r'\([^)]*\)'), '') // strip (parens)
- .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') // non-alphanum → space
- .trim()
- .split(RegExp(r'\s+'))
- .where((t) => t.length > 1) // drop single-char noise
- .toSet();
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // Platform-specific save base path
- // ═══════════════════════════════════════════════════════════════════════════
-
Future<String> _getEdenSaveBase({String? platformSlug}) async {
final String resolvedPath;
if (io.Platform.isMacOS || io.Platform.isLinux) {
resolvedPath = await _directoryService.getEmulatorAppSupportDirectory('eden', platformSlug: platformSlug);
} else if (io.Platform.isWindows) {
- final appData = io.Platform.environment['APPDATA'] ?? '';
- resolvedPath = p.join(appData, 'eden');
+ resolvedPath = p.join(io.Platform.environment['APPDATA'] ?? '', 'eden');
} else {
- throw UnsupportedError('Platform not supported for Eden save path resolution');
+ throw UnsupportedError('Platform not supported');
}
-
final finalPath = p.join(resolvedPath, 'nand', 'user', 'save');
- if (!await io.Directory(finalPath).exists()) {
- throw Exception('Save directory not found for Eden at $finalPath. Please launch Eden at least once to generate save data.');
- }
+ if (!await io.Directory(finalPath).exists()) throw Exception('Save directory not found for Eden.');
return finalPath;
}
- // ═══════════════════════════════════════════════════════════════════════════
- // Public API — SaveStrategy overrides
- // ═══════════════════════════════════════════════════════════════════════════
-
@override
Future<String?> getSaveDir(Game game, String romPath) async {
- debugPrint('=== EDEN SAVE DIR: ${game.name} ===');
-
final base = await _getEdenSaveBase(platformSlug: game.platformSlug);
-
final profileId = await _resolveProfileId(base);
final titleId = await _resolveTitleId(romPath, game);
-
- // Single 0000...00 layer, then profile, then title
- final finalPath =
- p.join(base, '0000000000000000', profileId, titleId);
- debugPrint('[Eden] Final path: $finalPath');
-
- if (io.Directory(finalPath).existsSync()) {
- return finalPath;
- }
-
- // Path doesn't exist yet — return it anyway.
- // pushSaves checks for files inside, pull creates the dir during restore.
- debugPrint('[Eden] Path does not exist on disk yet: $finalPath');
- return finalPath;
+ return p.join(base, '0000000000000000', profileId, titleId);
}
@override
- Future<List<io.File>> getSaveFiles(
- Game game,
- String romPath, {
- DateTime? sessionStart,
- String syncMode = 'both',
- }) async {
+ Future<List<io.File>> getSaveFiles(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
final saveDir = await getSaveDir(game, romPath);
if (saveDir == null) return [];
-
final dir = io.Directory(saveDir);
if (!dir.existsSync()) {
- if (syncMode == 'push') {
- throw Exception('Local save data not found for pushing.');
- }
+ if (syncMode == 'push') throw Exception('Local save data not found.');
return [];
}
-
- // Check if directory actually contains save files
- final files = dir
- .listSync(recursive: true)
- .whereType<io.File>()
- .where((f) {
+ final files = dir.listSync(recursive: true).whereType<io.File>().where((f) {
final name = p.basename(f.path);
return !name.startsWith('.') && !name.endsWith('.bak');
}).toList();
-
- if (files.isEmpty) {
- if (syncMode == 'push') {
- throw Exception(
- 'Save directory exists but contains no save files.');
- }
- return [];
- }
-
- // If sessionStart filter is set, check if any file was modified after it
- if (sessionStart != null) {
- final hasChanges = files.any(
- (f) => f.statSync().modified.isAfter(sessionStart));
- if (!hasChanges) {
- debugPrint('[Eden] No files modified since session start');
- return [];
- }
- }
-
- // Return the DIRECTORY as a File reference.
- // SaveSyncService.pushSaves() detects isDirectory and zips it.
+ if (files.isEmpty && syncMode == 'push') throw Exception('No save files found.');
+ if (sessionStart != null && !files.any((f) => f.statSync().modified.isAfter(sessionStart))) return [];
return [io.File(saveDir)];
}
@override
- Future<bool> restoreSave(
- Game game,
- String destPath, // This is the ROM path, passed through from pullSave
- Uint8List data,
- String filename,
- ) async {
- try {
- debugPrint('=== EDEN RESTORE: ${game.name} ===');
-
- final base = await _getEdenSaveBase(platformSlug: game.platformSlug);
-
- final profileId = await _resolveProfileId(base);
-
- // Resolve Title ID using the ROM path (destPath = romPath from pullSave)
- String? titleId;
-
- // 1. Header scan on the ROM
- final (headerId, resolvedPath) = await extractTitleIdFromHeader(destPath);
- titleId = headerId;
- if (titleId != null) {
- debugPrint('[Eden][Restore] Title ID from header: $titleId');
- }
-
- // 2. Filename extraction
- if (titleId == null) {
- titleId = _extractTitleIdFromFilename(resolvedPath ?? destPath, game);
- if (titleId != null) {
- debugPrint('[Eden][Restore] Title ID from filename: $titleId');
- }
- }
-
- // 3. Check inside ZIP entries for Title ID folder names
- Archive? archive;
- if (titleId == null && filename.toLowerCase().endsWith('.zip')) {
- archive = ZipDecoder().decodeBytes(data);
- for (final entry in archive) {
- final match = _titleIdExtractorRegex.firstMatch(entry.name);
- if (match != null) {
- titleId = _normalizeToBaseId(match.group(0)!);
- debugPrint('[Eden][Restore] Title ID from ZIP entry: $titleId');
- break;
- }
- }
- }
-
- // 4. Manual mapping
- if (titleId == null &&
- _manualMapping != null &&
- _manualMapping!.isNotEmpty) {
- titleId = _manualMapping;
- debugPrint('[Eden][Restore] Title ID from manual mapping: $titleId');
- }
+ Future<bool> restoreSave(Game game, String destPath, Uint8List data, String filename) async {
+ final base = await _getEdenSaveBase(platformSlug: game.platformSlug);
+ final profileId = await _resolveProfileId(base);
+ String? titleId;
+ final (headerId, resolvedPath) = await extractTitleIdFromHeader(destPath);
+ titleId = headerId ?? _extractTitleIdFromFilename(resolvedPath ?? destPath, game);
- // 5. Give up
- if (titleId == null) {
- debugPrint('[Eden][Restore] FAILED: No Title ID resolved');
- throw SaveMappingRequiredException();
+ Archive? archive;
+ if (titleId == null && filename.toLowerCase().endsWith('.zip')) {
+ archive = ZipDecoder().decodeBytes(data);
+ for (final entry in archive) {
+ final match = _titleIdExtractorRegex.firstMatch(entry.name);
+ if (match != null) { titleId = _normalizeToBaseId(match.group(0)!); break; }
}
+ }
+ titleId ??= _manualMapping;
+ if (titleId == null) throw SaveMappingRequiredException();
- // Construct target and create if needed
- final targetPath = p.normalize(
- p.join(base, '0000000000000000', profileId, titleId));
- debugPrint('[Eden][Restore] Target: $targetPath');
- io.Directory(targetPath).createSync(recursive: true);
+ final targetPath = p.normalize(p.join(base, '0000000000000000', profileId, titleId));
+ io.Directory(targetPath).createSync(recursive: true);
- // Extract or write
- if (filename.toLowerCase().endsWith('.zip')) {
- archive ??= ZipDecoder().decodeBytes(data);
- return await _extractArchive(archive, targetPath);
- } else {
- final filePath = p.normalize(p.join(targetPath, filename));
- await backupSave(filePath);
- await io.File(filePath).writeAsBytes(data);
- debugPrint('[Eden][Restore] Wrote single file: $filePath');
- return true;
- }
- } catch (e) {
- if (e is SaveMappingRequiredException ||
- e is ProfileConflictException) {
- rethrow;
- }
- debugPrint('[Eden][Restore] ERROR: $e');
- rethrow;
+ if (filename.toLowerCase().endsWith('.zip')) {
+ archive ??= ZipDecoder().decodeBytes(data);
+ return await _extractArchive(archive, targetPath);
+ } else {
+ final filePath = p.normalize(p.join(targetPath, filename));
+ await backupSave(filePath);
+ await io.File(filePath).writeAsBytes(data);
+ return true;
}
}
- // ═══════════════════════════════════════════════════════════════════════════
- // Helpers
- // ═══════════════════════════════════════════════════════════════════════════
-
- /// Lists all Title ID save folders for the active profile.
- /// Used by the UI for manual folder selection.
Future<List<Map<String, dynamic>>> getAvailableSaveFolders() async {
final base = await _getEdenSaveBase();
-
- String profileId;
- try {
- profileId = await _resolveProfileId(base);
- } catch (_) {
- return [];
- }
-
- final profileDir =
- io.Directory(p.join(base, '0000000000000000', profileId));
+ final profileId = await _resolveProfileId(base);
+ final profileDir = io.Directory(p.join(base, '0000000000000000', profileId));
if (!profileDir.existsSync()) return [];
final folders = <Map<String, dynamic>>[];
for (final entity in profileDir.listSync()) {
- if (entity is! io.Directory) continue;
- final name = p.basename(entity.path);
- if (!_strictTitleIdRegex.hasMatch(name)) continue;
-
+ if (entity is! io.Directory || !RegExp(r'^01[0-9A-Fa-f]{14}$').hasMatch(p.basename(entity.path))) continue;
DateTime? newest;
- int fileCount = 0;
+ int count = 0;
for (final f in entity.listSync(recursive: true)) {
if (f is! io.File) continue;
- fileCount++;
+ count++;
final mod = f.statSync().modified;
if (newest == null || mod.isAfter(newest)) newest = mod;
}
-
- if (fileCount > 0) {
- folders.add({
- 'name': name,
- 'lastModified': newest,
- 'fileCount': fileCount,
- });
- }
+ if (count > 0) folders.add({'name': p.basename(entity.path), 'lastModified': newest, 'fileCount': count});
}
-
- folders.sort((a, b) => (b['lastModified'] as DateTime)
- .compareTo(a['lastModified'] as DateTime));
+ folders.sort((a, b) => (b['lastModified'] as DateTime).compareTo(a['lastModified'] as DateTime));
return folders;
}
- /// Extracts a ZIP archive into [destDir], stripping a leading container
- /// folder if present (Title ID, User ID, or numeric index).
Future<bool> _extractArchive(Archive archive, String destDir) async {
try {
- final userIdRegex = RegExp(r'^[0-9A-Fa-f]{16}$');
- final numericRegex = RegExp(r'^\d+$');
-
+ final tidRegex = RegExp(r'^01[0-9A-Fa-f]{14}$');
for (final entry in archive) {
if (entry.name.isEmpty || entry.name == 'freegosy_sync.txt' || entry.name.contains('.bak')) continue;
-
- // Strip leading container folder if present.
- // Handles: "0100704000B3A000/..." (Eden)
- // Handles: "0000000000000001/..." (Ryujinx User)
- // Handles: "0/..." (Ryujinx Index)
final segments = entry.name.split(RegExp(r'[/\\]'));
final first = segments.first;
- final entryPath = (segments.length > 1 &&
- (_strictTitleIdRegex.hasMatch(first) ||
- userIdRegex.hasMatch(first) ||
- numericRegex.hasMatch(first)))
- ? p.joinAll(segments.sublist(1))
- : entry.name;
-
+ final entryPath = (segments.length > 1 && (tidRegex.hasMatch(first) || RegExp(r'^[0-9A-Fa-f]{16}$').hasMatch(first) || RegExp(r'^\d+$').hasMatch(first)))
+ ? p.joinAll(segments.sublist(1)) : entry.name;
if (entryPath.isEmpty) continue;
-
final outPath = p.normalize(p.join(destDir, entryPath));
if (entry.isFile) {
await backupSave(outPath);
final outFile = io.File(outPath);
await outFile.parent.create(recursive: true);
await outFile.writeAsBytes(entry.content as List<int>);
- debugPrint('[Eden][Extract] ${entry.name} → $outPath');
- } else {
- await io.Directory(outPath).create(recursive: true);
- }
+ } else { await io.Directory(outPath).create(recursive: true); }
}
return true;
- } catch (e) {
- debugPrint('[Eden][Extract] ERROR: $e');
- rethrow;
- }
+ } catch (_) { rethrow; }
}
}
diff --git a/lib/core/save/strategies/melonds_save_strategy.dart b/lib/core/save/strategies/melonds_save_strategy.dart
index e7d4cd2..4c2f733 100644
--- a/lib/core/save/strategies/melonds_save_strategy.dart
+++ b/lib/core/save/strategies/melonds_save_strategy.dart
@@ -1,26 +1,54 @@
import 'dart:io' as io;
import 'dart:typed_data';
import 'package:path/path.dart' as p;
+import 'package:archive/archive_io.dart';
import '../../romm/romm_models.dart';
import '../../storage/directory_service.dart';
import '../save_strategy.dart';
/// Save strategy for melonDS (Nintendo DS).
+///
+/// Checks the RetroArch save directory as fallback for users running
+/// RetroArch with the melonDS / DeSmuME core.
class MelonDsSaveStrategy extends SaveStrategy {
final DirectoryService _directoryService;
+ String? _cachedRetroarchDir;
MelonDsSaveStrategy(this._directoryService);
@override
String get strategyId => 'melonds';
+ @override
+ bool get shouldZip => false;
+
@override
Future<String?> getSaveDir(Game game, String romPath) async {
if (io.Platform.isLinux) {
final emuDir = await _directoryService.getEmulatorAppSupportDirectory('melonds');
if (await io.Directory(emuDir).exists()) return emuDir;
}
- return io.File(romPath).parent.path;
+
+ final romDir = io.File(romPath).parent.path;
+
+ // 1. ROM-adjacent (standalone melonDS)
+ if (await io.Directory(romDir).exists()) {
+ final stem = p.basenameWithoutExtension(romPath).toLowerCase();
+ final dir = io.Directory(romDir);
+ await for (final entity in dir.list()) {
+ if (entity is io.File) {
+ final fname = p.basename(entity.path).toLowerCase();
+ if (fname == '$stem.sav' || fname == '$stem.srm') return romDir;
+ }
+ }
+ }
+
+ // 2. Fallback: RetroArch melonDS/DeSmuME core save directory
+ _cachedRetroarchDir ??= await SaveStrategy.retroarchCoreSaveDir(_directoryService, 'NDS');
+ if (_cachedRetroarchDir != null) return _cachedRetroarchDir;
+
+ // 3. Absolute fallback
+ return romDir;
}
@override
@@ -31,7 +59,12 @@ class MelonDsSaveStrategy extends SaveStrategy {
final romStem = p.basenameWithoutExtension(romPath).toLowerCase();
final fallbackStem = getRomStem(game).toLowerCase();
-
+ final stemWords = romStem
+ .replaceAll(RegExp(r'[^a-z0-9]'), ' ')
+ .split(' ')
+ .where((w) => w.length >= 3)
+ .toList();
+
final dir = io.Directory(saveDir);
if (!await dir.exists()) return [];
@@ -39,15 +72,33 @@ class MelonDsSaveStrategy extends SaveStrategy {
await for (final entity in dir.list()) {
if (entity is! io.File) continue;
final fname = p.basename(entity.path).toLowerCase();
- if ((fname == '$romStem.sav' || fname == '$fallbackStem.sav')) {
+ if ((fname == '$romStem.sav' || fname == '$fallbackStem.sav' || fname == '$romStem.srm' || fname == '$fallbackStem.srm')) {
if (sessionStart != null) {
final stat = await entity.stat();
if (stat.modified.isBefore(sessionStart)) continue;
}
foundFiles.add(entity);
- break;
+ break;
}
}
+
+ // Fuzzy fallback: match by word tokens (e.g. "Dragon Ball Z: Legacy" matches "Dragon Ball Z - Legacy.srm")
+ if (foundFiles.isEmpty && stemWords.isNotEmpty) {
+ await for (final entity in dir.list()) {
+ if (entity is! io.File) continue;
+ final fname = p.basename(entity.path).toLowerCase();
+ if (!fname.endsWith('.srm') && !fname.endsWith('.sav')) continue;
+ if (stemWords.any((word) => fname.contains(word))) {
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
+ }
+ foundFiles.add(entity);
+ break;
+ }
+ }
+ }
+
return foundFiles;
}
@@ -62,16 +113,50 @@ class MelonDsSaveStrategy extends SaveStrategy {
final fallbackStem = getRomStem(game).toLowerCase();
String targetPath = p.normalize(p.join(saveDir, '$romStem.sav'));
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final file in archive) {
+ if (!file.isFile) continue;
+ if (file.name == 'freegosy_sync.txt') continue;
+ if (file.name.toLowerCase().endsWith('.sav')) {
+ await io.Directory(p.dirname(targetPath)).create(recursive: true);
+ await backupSave(targetPath);
+ await io.File(targetPath).writeAsBytes(file.content);
+ return true;
+ }
+ }
+ return true;
+ }
+
final dir = io.Directory(saveDir);
if (await dir.exists()) {
await for (final entity in dir.list()) {
if (entity is! io.File) continue;
final fname = p.basename(entity.path).toLowerCase();
- if (fname == '$romStem.sav' || fname == '$fallbackStem.sav') {
+ if (fname == '$romStem.sav' || fname == '$fallbackStem.sav' || fname == '$romStem.srm' || fname == '$fallbackStem.srm') {
targetPath = entity.path;
break;
}
}
+ // Fuzzy fallback if strict match failed
+ if (targetPath == p.normalize(p.join(saveDir, '$romStem.sav'))) {
+ final stemWords = romStem
+ .replaceAll(RegExp(r'[^a-z0-9]'), ' ')
+ .split(' ')
+ .where((w) => w.length >= 3)
+ .toList();
+ if (stemWords.isNotEmpty) {
+ await for (final entity in dir.list()) {
+ if (entity is! io.File) continue;
+ final fname = p.basename(entity.path).toLowerCase();
+ if (!fname.endsWith('.srm') && !fname.endsWith('.sav')) continue;
+ if (stemWords.any((word) => fname.contains(word))) {
+ targetPath = entity.path;
+ break;
+ }
+ }
+ }
+ }
}
await io.Directory(p.dirname(targetPath)).create(recursive: true);
diff --git a/lib/core/save/strategies/mgba_save_strategy.dart b/lib/core/save/strategies/mgba_save_strategy.dart
index 612879c..6cfc80e 100644
--- a/lib/core/save/strategies/mgba_save_strategy.dart
+++ b/lib/core/save/strategies/mgba_save_strategy.dart
@@ -1,26 +1,53 @@
import 'dart:io' as io;
import 'dart:typed_data';
import 'package:path/path.dart' as p;
+import 'package:archive/archive_io.dart';
import '../../romm/romm_models.dart';
import '../../storage/directory_service.dart';
import '../save_strategy.dart';
/// Save strategy for mGBA (GBA/GBC/GB).
+///
+/// Checks the RetroArch save directory as fallback for users running
+/// RetroArch with the mGBA core.
class MgbaSaveStrategy extends SaveStrategy {
final DirectoryService _directoryService;
+ String? _cachedRetroarchDir;
MgbaSaveStrategy(this._directoryService);
@override
String get strategyId => 'mgba';
+ @override
+ bool get shouldZip => false;
+
@override
Future<String?> getSaveDir(Game game, String romPath) async {
if (io.Platform.isLinux) {
final emuDir = await _directoryService.getEmulatorAppSupportDirectory('mgba');
if (await io.Directory(emuDir).exists()) return emuDir;
}
- return io.File(romPath).parent.path;
+
+ // 1. ROM-adjacent (standalone mGBA)
+ final romDir = io.File(romPath).parent.path;
+ if (await io.Directory(romDir).exists()) {
+ final stem = p.basenameWithoutExtension(romPath).toLowerCase();
+ final dir = io.Directory(romDir);
+ await for (final entity in dir.list()) {
+ if (entity is io.File) {
+ final fname = p.basename(entity.path).toLowerCase();
+ if (fname == '$stem.sav' || fname == '$stem.srm') return romDir;
+ }
+ }
+ }
+
+ // 2. Fallback: RetroArch mGBA core save directory
+ _cachedRetroarchDir ??= await SaveStrategy.retroarchCoreSaveDir(_directoryService, 'mGBA');
+ if (_cachedRetroarchDir != null) return _cachedRetroarchDir;
+
+ // 3. Absolute fallback: ROM directory
+ return romDir;
}
@override
@@ -31,7 +58,12 @@ class MgbaSaveStrategy extends SaveStrategy {
final romStem = p.basenameWithoutExtension(romPath).toLowerCase();
final fallbackStem = getRomStem(game).toLowerCase();
-
+ final stemWords = romStem
+ .replaceAll(RegExp(r'[^a-z0-9]'), ' ')
+ .split(' ')
+ .where((w) => w.length >= 3)
+ .toList();
+
final dir = io.Directory(saveDir);
if (!await dir.exists()) return [];
@@ -39,15 +71,33 @@ class MgbaSaveStrategy extends SaveStrategy {
await for (final entity in dir.list()) {
if (entity is! io.File) continue;
final fname = p.basename(entity.path).toLowerCase();
- if ((fname == '$romStem.sav' || fname == '$fallbackStem.sav')) {
+ if (fname == '$romStem.sav' || fname == '$fallbackStem.sav' || fname == '$romStem.srm' || fname == '$fallbackStem.srm') {
if (sessionStart != null) {
final stat = await entity.stat();
if (stat.modified.isBefore(sessionStart)) continue;
}
foundFiles.add(entity);
- break;
+ break;
+ }
+ }
+
+ // Fuzzy fallback: match by word tokens (e.g. "Dragon Ball Z: Legacy" matches "Dragon Ball Z - Legacy.srm")
+ if (foundFiles.isEmpty && stemWords.isNotEmpty) {
+ await for (final entity in dir.list()) {
+ if (entity is! io.File) continue;
+ final fname = p.basename(entity.path).toLowerCase();
+ if (!fname.endsWith('.srm') && !fname.endsWith('.sav')) continue;
+ if (stemWords.any((word) => fname.contains(word))) {
+ if (sessionStart != null) {
+ final stat = await entity.stat();
+ if (stat.modified.isBefore(sessionStart)) continue;
+ }
+ foundFiles.add(entity);
+ break;
+ }
}
}
+
return foundFiles;
}
@@ -62,16 +112,50 @@ class MgbaSaveStrategy extends SaveStrategy {
final fallbackStem = getRomStem(game).toLowerCase();
String targetPath = p.normalize(p.join(saveDir, '$romStem.sav'));
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final file in archive) {
+ if (!file.isFile) continue;
+ if (file.name == 'freegosy_sync.txt') continue;
+ if (file.name.toLowerCase().endsWith('.sav')) {
+ await io.Directory(p.dirname(targetPath)).create(recursive: true);
+ await backupSave(targetPath);
+ await io.File(targetPath).writeAsBytes(file.content);
+ return true;
+ }
+ }
+ return true;
+ }
+
final dir = io.Directory(saveDir);
if (await dir.exists()) {
await for (final entity in dir.list()) {
if (entity is! io.File) continue;
final fname = p.basename(entity.path).toLowerCase();
- if (fname == '$romStem.sav' || fname == '$fallbackStem.sav') {
+ if (fname == '$romStem.sav' || fname == '$fallbackStem.sav' || fname == '$romStem.srm' || fname == '$fallbackStem.srm') {
targetPath = entity.path;
break;
}
}
+ // Fuzzy fallback if strict match failed
+ if (targetPath == p.normalize(p.join(saveDir, '$romStem.sav'))) {
+ final stemWords = romStem
+ .replaceAll(RegExp(r'[^a-z0-9]'), ' ')
+ .split(' ')
+ .where((w) => w.length >= 3)
+ .toList();
+ if (stemWords.isNotEmpty) {
+ await for (final entity in dir.list()) {
+ if (entity is! io.File) continue;
+ final fname = p.basename(entity.path).toLowerCase();
+ if (!fname.endsWith('.srm') && !fname.endsWith('.sav')) continue;
+ if (stemWords.any((word) => fname.contains(word))) {
+ targetPath = entity.path;
+ break;
+ }
+ }
+ }
+ }
}
await io.Directory(p.dirname(targetPath)).create(recursive: true);
diff --git a/lib/core/save/strategies/retroarch_save_strategy.dart b/lib/core/save/strategies/retroarch_save_strategy.dart
index a3a31d5..f05b6b0 100644
--- a/lib/core/save/strategies/retroarch_save_strategy.dart
+++ b/lib/core/save/strategies/retroarch_save_strategy.dart
@@ -1,5 +1,6 @@
import 'dart:io' as io;
-import 'dart:typed_data';
+import 'package:archive/archive_io.dart';
+import 'package:flutter/foundation.dart';
import '../../romm/romm_models.dart';
import '../../storage/directory_service.dart';
@@ -13,6 +14,11 @@ import 'package:path/path.dart' as p; // Import path package
class RetroArchSaveStrategy extends SaveStrategy {
final DirectoryService _directoryService;
String _ndsCore = 'melonds'; // Default NDS core
+ String? _cachedSaveRoot; // Cached from retroarch.cfg
+
+ // Test-only override to skip reading the real retroarch.cfg.
+ @visibleForTesting
+ bool skipConfigRead = false;
RetroArchSaveStrategy(this._directoryService);
@@ -23,16 +29,19 @@ class RetroArchSaveStrategy extends SaveStrategy {
@override
String get strategyId => 'retroarch';
+ @override
+ bool get shouldZip => false;
+
// _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'), // mGBA uses the same save folder for GBA/GBC/GB
'gb': _CoreInfo('mgba_libretro', 'mGBA', 'mGBA'),
- 'snes': _CoreInfo('snes9x_libretro', 'SNES', 'States/SNES'),
+ 'snes': _CoreInfo('snes9x_libretro', 'Snes9x', 'Snes9x'),
'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'),
+ 'psx': _CoreInfo('pcsx_rearmed_libretro', 'PCSX-ReARMed', 'PCSX-ReARMed'),
'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
@@ -43,6 +52,71 @@ class RetroArchSaveStrategy extends SaveStrategy {
'md': _CoreInfo('genesis_plus_gx_libretro', 'Mega Drive', 'States/Mega Drive'), // Alias for Mega Drive
};
+ /// Reads `savefile_directory` from retroarch.cfg if available.
+ Future<String?> _readConfigSaveRoot() async {
+ if (skipConfigRead) return null;
+ if (_cachedSaveRoot != null) return _cachedSaveRoot;
+
+ final List<String> candidates = [];
+
+ if (io.Platform.isMacOS) {
+ final home = io.Platform.environment['HOME'] ?? '';
+ candidates.add(p.join(home, 'Library', 'Application Support', 'RetroArch', 'config', 'retroarch.cfg'));
+ candidates.add(p.join(home, '.config', 'retroarch', 'retroarch.cfg'));
+ } else if (io.Platform.isLinux) {
+ final home = io.Platform.environment['HOME'] ?? '';
+ candidates.add(p.join(home, '.config', 'retroarch', 'retroarch.cfg'));
+ } else if (io.Platform.isWindows) {
+ final appData = io.Platform.environment['APPDATA'] ?? '';
+ candidates.add(p.join(appData, 'RetroArch', 'retroarch.cfg'));
+ }
+
+ // Also check next to the bundled exe
+ final exePath = await _directoryService.findEmulatorExecutable('retroarch', _getRetroArchExe());
+ if (exePath != null) {
+ String exeDir = io.Platform.isMacOS
+ ? p.join(io.File(exePath).parent.parent.parent.parent.path)
+ : io.File(exePath).parent.path;
+ if (await io.FileSystemEntity.isDirectory(exePath)) exeDir = exePath;
+ candidates.add(p.join(exeDir, 'retroarch.cfg'));
+ }
+
+ for (final cfgPath in candidates) {
+ final cfgFile = io.File(cfgPath);
+ if (!await cfgFile.exists()) continue;
+ try {
+ final lines = await cfgFile.readAsLines();
+ for (final line in lines) {
+ final match = RegExp(r'^\s*savefile_directory\s*=\s*"([^"]*)"').firstMatch(line);
+ if (match != null) {
+ var dir = match.group(1)!;
+ if (dir.startsWith('~')) {
+ final home = io.Platform.environment['HOME'];
+ if (home != null) dir = dir.replaceFirst('~', home);
+ }
+ if (await io.Directory(dir).exists()) {
+ _cachedSaveRoot = dir;
+ return dir;
+ }
+ }
+ }
+ } catch (_) {}
+ }
+ return null;
+ }
+
+ /// Resolves the save root directory: retroarch.cfg first, then exe-relative.
+ Future<String> _resolveSaveRoot() async {
+ final cfg = await _readConfigSaveRoot();
+ if (cfg != null) return cfg;
+ final exePath = await _directoryService.findEmulatorExecutable('retroarch', _getRetroArchExe());
+ String exeDir = io.Platform.isMacOS
+ ? p.join(io.File(exePath!).parent.parent.parent.parent.path)
+ : io.File(exePath!).parent.path;
+ if (await io.FileSystemEntity.isDirectory(exePath)) exeDir = exePath;
+ return p.join(exeDir, 'saves');
+ }
+
String _getRetroArchExe() {
if (io.Platform.isWindows) return 'RetroArch.exe';
if (io.Platform.isMacOS) return 'RetroArch.app/Contents/MacOS/RetroArch';
@@ -53,7 +127,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
Future<String?> getSaveDir(Game game, String romPath) async {
final slug = game.platformSlug?.toLowerCase() ?? '';
_CoreInfo? coreInfo = _coreMap[slug];
-
+
// Dynamic override for NDS based on user preference
if (slug == 'nds' || slug == 'nintendo-ds') {
coreInfo = _ndsCore == 'desmume'
@@ -65,7 +139,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
if (io.Platform.isLinux) {
final baseDir = await _directoryService.getEmulatorAppSupportDirectory('retroarch', platformSlug: slug);
-
+
if (_directoryService.linuxSyncPreset == 'emudeck' || baseDir.contains('Emulation/saves')) {
// EmuDeck structure: Emulation/saves/retroarch/saves/CoreName
if (p.basename(baseDir) == 'saves') {
@@ -73,21 +147,13 @@ class RetroArchSaveStrategy extends SaveStrategy {
}
return p.join(baseDir, 'saves', coreInfo.saveFolder);
}
-
+
// EmuDeck mapping returns the folder containing the actual saves
return p.join(baseDir, coreInfo.saveFolder);
}
- final exePath = await _directoryService.findEmulatorExecutable('retroarch', _getRetroArchExe());
- if (exePath == null) return null;
-
- String exeDir = io.Platform.isMacOS ? p.join(io.File(exePath).parent.parent.parent.parent.path) : io.File(exePath).parent.path;
- // If exePath points to a folder instead of an exe, use it directly
- if (await io.FileSystemEntity.isDirectory(exePath)) {
- exeDir = exePath;
- }
- // The saveFolder in _coreMap is relative to the RetroArch installation directory.
- return p.join(exeDir, 'saves', coreInfo.saveFolder);
+ final saveRoot = await _resolveSaveRoot();
+ return p.join(saveRoot, coreInfo.saveFolder);
}
@override
@@ -100,7 +166,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
Future<Map<io.File, io.File?>> getSaveFilesWithScreenshots(Game game, String romPath, {DateTime? sessionStart, String syncMode = 'both'}) async {
final slug = game.platformSlug?.toLowerCase() ?? '';
_CoreInfo? coreInfo = _coreMap[slug];
-
+
// Dynamic override for NDS based on user preference
if (slug == 'nds' || slug == 'nintendo-ds') {
coreInfo = _ndsCore == 'desmume'
@@ -116,7 +182,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
if (io.Platform.isLinux) {
rootSaveDir = await getSaveDir(game, romPath);
final baseDir = await _directoryService.getEmulatorAppSupportDirectory('retroarch', platformSlug: slug);
-
+
if (_directoryService.linuxSyncPreset == 'emudeck') {
// EmuDeck: saves are in Emulation/saves/retroarch, states in Emulation/states/retroarch
// baseDir is .../Emulation/saves/retroarch
@@ -129,15 +195,11 @@ class RetroArchSaveStrategy extends SaveStrategy {
statesRoot = p.join(p.dirname(baseDir), 'states', coreInfo.statesFolder);
}
} else {
- final exePath = await _directoryService.findEmulatorExecutable('retroarch', _getRetroArchExe());
- if (exePath == null) return {};
-
- String exeDir = io.Platform.isMacOS ? p.join(io.File(exePath).parent.parent.parent.parent.path) : io.File(exePath).parent.path;
- if (await io.FileSystemEntity.isDirectory(exePath)) {
- exeDir = exePath;
- }
- rootSaveDir = p.join(exeDir, 'saves', coreInfo.saveFolder);
- statesRoot = p.join(exeDir, 'states', coreInfo.statesFolder);
+ rootSaveDir = await getSaveDir(game, romPath);
+
+ // States use the same root but under a states/ subfolder
+ final saveRoot = await _resolveSaveRoot();
+ statesRoot = p.join(io.Directory(saveRoot).parent.path, 'states', coreInfo.statesFolder);
}
if (rootSaveDir == null) return {};
@@ -169,7 +231,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
await for (final entity in savesDirObj.list()) {
if (entity is! io.File) continue;
final fname = p.basename(entity.path).toLowerCase();
- if (fname.startsWith(stemLower) && (fname.endsWith('.srm') || fname.endsWith('.sav'))) {
+ if (fname.startsWith(stemLower) && (fname.endsWith('.srm') || fname.endsWith('.sav') || fname.endsWith('.mcd'))) {
filesToCheck.add(entity);
found = true;
break;
@@ -179,7 +241,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
await for (final entity in savesDirObj.list()) {
if (entity is! io.File) continue;
final fname = p.basename(entity.path).toLowerCase();
- if (fname.endsWith('.srm') || fname.endsWith('.sav')) {
+ if (fname.endsWith('.srm') || fname.endsWith('.sav') || fname.endsWith('.mcd')) {
final stemWords = stemLower
.replaceAll(RegExp(r'[^a-z0-9]'), ' ')
.split(' ')
@@ -194,7 +256,19 @@ class RetroArchSaveStrategy extends SaveStrategy {
}
}
if (!found) {
- filesToCheck.add(io.File(p.join(rootSaveDir, '$stem.srm')));
+ // Last resort: scan directory for ANY .srm/.sav file
+ await for (final entity in savesDirObj.list()) {
+ if (entity is! io.File) continue;
+ final fname = p.basename(entity.path).toLowerCase();
+ if (fname.endsWith('.srm') || fname.endsWith('.sav') || fname.endsWith('.mcd')) {
+ filesToCheck.add(entity);
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ filesToCheck.add(io.File(p.join(rootSaveDir, '$stem.srm')));
+ }
}
} else {
filesToCheck.add(io.File(p.join(rootSaveDir, '$stem.srm')));
@@ -223,7 +297,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
final stat = await f.stat();
if (stat.modified.isBefore(sessionStart)) continue;
}
-
+
// Check for screenshots if it's a state file
io.File? screenshot;
if (f.path.contains('.state')) {
@@ -233,7 +307,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
screenshot = screenFile;
}
}
-
+
finalResult[f] = screenshot;
}
return finalResult;
@@ -244,7 +318,7 @@ class RetroArchSaveStrategy extends SaveStrategy {
try {
final slug = game.platformSlug?.toLowerCase() ?? '';
_CoreInfo? coreInfo = _coreMap[slug];
-
+
// Dynamic override for NDS based on user preference
if (slug == 'nds' || slug == 'nintendo-ds') {
coreInfo = _ndsCore == 'desmume'
@@ -254,12 +328,59 @@ class RetroArchSaveStrategy extends SaveStrategy {
if (coreInfo == null) return false;
+ if (filename.toLowerCase().endsWith('.zip')) {
+ final archive = ZipDecoder().decodeBytes(data);
+ for (final file in archive) {
+ if (!file.isFile) continue;
+ if (file.name == 'freegosy_sync.txt') continue;
+
+ final isFileState = file.name.contains('.state');
+ String? fileTargetDir;
+ if (io.Platform.isLinux) {
+ final baseDir = await _directoryService.getEmulatorAppSupportDirectory('retroarch', platformSlug: slug);
+ if (_directoryService.linuxSyncPreset == 'emudeck') {
+ if (isFileState) {
+ final emulationRoot = p.dirname(p.dirname(baseDir));
+ fileTargetDir = p.join(emulationRoot, 'states', 'retroarch', coreInfo.statesFolder);
+ } else {
+ fileTargetDir = p.join(baseDir, coreInfo.saveFolder);
+ }
+ } else if (_directoryService.linuxSyncPreset == 'retrodeck') {
+ fileTargetDir = isFileState
+ ? p.join(baseDir, 'states', coreInfo.statesFolder)
+ : p.join(baseDir, 'saves', coreInfo.saveFolder);
+ } else {
+ fileTargetDir = isFileState
+ ? p.join(p.dirname(baseDir), 'states', coreInfo.statesFolder)
+ : p.join(baseDir, coreInfo.saveFolder);
+ }
+ } else {
+ final saveRoot = await _resolveSaveRoot();
+ fileTargetDir = isFileState
+ ? p.join(io.Directory(saveRoot).parent.path, 'states', coreInfo.statesFolder)
+ : p.join(saveRoot, coreInfo.saveFolder);
+ }
+ final dir = io.Directory(fileTargetDir);
+ if (!await dir.exists()) await dir.create(recursive: true);
+
+ String targetFilename = file.name;
+ if (!isFileState && file.name.toLowerCase().endsWith('.sav')) {
+ targetFilename = '${p.basenameWithoutExtension(file.name)}.srm';
+ }
+
+ final targetPath = p.normalize(p.join(fileTargetDir, targetFilename));
+ await backupSave(targetPath);
+ await io.File(targetPath).writeAsBytes(file.content);
+ }
+ return true;
+ }
+
String? targetDir;
final isState = filename.contains('.state');
if (io.Platform.isLinux) {
final baseDir = await _directoryService.getEmulatorAppSupportDirectory('retroarch', platformSlug: slug);
-
+
if (_directoryService.linuxSyncPreset == 'emudeck') {
if (isState) {
final emulationRoot = p.dirname(p.dirname(baseDir));
@@ -277,16 +398,10 @@ class RetroArchSaveStrategy extends SaveStrategy {
: p.join(baseDir, coreInfo.saveFolder);
}
} else {
- final exePath = await _directoryService.findEmulatorExecutable('retroarch', _getRetroArchExe());
- if (exePath == null) return false;
-
- String exeDir = io.Platform.isMacOS ? p.join(io.File(exePath).parent.parent.parent.parent.path) : io.File(exePath).parent.path;
- if (await io.FileSystemEntity.isDirectory(exePath)) {
- exeDir = exePath;
- }
+ final saveRoot = await _resolveSaveRoot();
targetDir = isState
- ? p.join(exeDir, 'states', coreInfo.statesFolder)
- : p.join(exeDir, 'saves', coreInfo.saveFolder);
+ ? p.join(io.Directory(saveRoot).parent.path, 'states', coreInfo.statesFolder)
+ : p.join(saveRoot, coreInfo.saveFolder);
}
final dir = io.Directory(targetDir);
diff --git a/lib/core/storage/directory_service.dart b/lib/core/storage/directory_service.dart
index ab53601..079fc3f 100644
--- a/lib/core/storage/directory_service.dart
+++ b/lib/core/storage/directory_service.dart
@@ -1,111 +1,24 @@
import 'dart:io' as io;
-import 'dart:io' show Directory, File, Process, FileSystemEntity;
+import 'dart:io' show Directory, File, Process;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:freegosy/core/romm/romm_models.dart';
+import 'package:freegosy/core/romm/rom_constants.dart';
+import 'package:freegosy/core/storage/file_system_index.dart';
+import 'package:freegosy/core/storage/rom_lookup_service.dart';
import 'package:freegosy/core/emulator/linux_strategies/linux_environment_strategy.dart';
import 'package:freegosy/core/emulator/linux_strategies/native_linux_strategy.dart';
import 'package:freegosy/core/emulator/linux_strategies/emudeck_strategy.dart';
import 'package:freegosy/core/emulator/linux_strategies/retrodeck_strategy.dart';
-enum StorageError { none, pathNotFound, permissionDenied, unknown }
-
-class StorageStatus {
- final StorageError error;
- final String? message;
- final String? failedPath;
-
- const StorageStatus({this.error = StorageError.none, this.message, this.failedPath});
-
- bool get hasError => error != StorageError.none;
-}
-
-class FileSystemIndex {
- final String rootPath;
- final Map<String, String> files; // lowercase name -> absolute path
- final Map<String, String> dirs; // lowercase name -> absolute path
- final Map<String, int> fileSizes; // absolute path -> size
-
- FileSystemIndex({
- required this.rootPath,
- required this.files,
- required this.dirs,
- required this.fileSizes,
- });
-
- static Future<FileSystemIndex> build(String path) async {
- final Map<String, String> files = {};
- final Map<String, String> dirs = {};
- final Map<String, int> fileSizes = {};
-
- final rootDir = io.Directory(path);
- if (await rootDir.exists()) {
- try {
- await for (final entity in rootDir.list(recursive: false)) {
- final name = p.basename(entity.path).toLowerCase();
- if (entity is io.File) {
- files[name] = p.absolute(entity.path);
- try {
- fileSizes[p.absolute(entity.path)] = await entity.length();
- } catch (_) {}
- } else if (entity is io.Directory) {
- dirs[name] = p.absolute(entity.path);
- }
- }
- } catch (_) {}
- }
-
- return FileSystemIndex(
- rootPath: path,
- files: files,
- dirs: dirs,
- fileSizes: fileSizes,
- );
- }
-}
-
class DirectoryService {
static const String _romsRootPathKey = 'romsRootPath';
static const String _emulatorsRootPathKey = 'emulatorsRootPath';
static const String _linuxSyncPresetKey = 'linuxSyncPreset';
- static const String _linuxPresetRootKey = 'emudeckRootPath'; // Keeping key name for compatibility
-
- // Known extensions per platform slug
- static const Map<String, List<String>> platformExtensions = {
- 'switch': ['.nsp', '.xci', '.nsz', '.xcz'],
- 'nintendo-switch': ['.nsp', '.xci', '.nsz', '.xcz'],
- 'ns': ['.nsp', '.xci', '.nsz', '.xcz'],
- 'gba': ['.gba', '.zip', '.7z'],
- 'gbc': ['.gbc', '.gb', '.zip', '.7z'],
- 'gb': ['.gb', '.gbc', '.zip', '.7z'],
- 'nds': ['.nds', '.zip', '.7z'],
- 'n64': ['.z64', '.n64', '.v64', '.zip', '.7z'],
- 'snes': ['.sfc', '.smc', '.zip', '.7z'],
- 'nes': ['.nes', '.zip', '.7z'],
- 'psx': ['.bin', '.cue', '.iso', '.img', '.chd', '.pbp'],
- 'ps2': ['.iso', '.bin', '.chd'],
- 'ps3': ['.pkg', '.iso', '.bin', '.edat'],
- 'psp': ['.iso', '.cso', '.pbp'],
- 'gc': ['.iso', '.gcm', '.rvz', '.wbfs'],
- 'gamecube': ['.iso', '.gcm', '.rvz', '.wbfs'],
- 'wii': ['.iso', '.wbfs', '.rvz'],
- 'dreamcast': ['.chd', '.gdi', '.cdi', '.iso'],
- 'megadrive': ['.md', '.bin', '.gen', '.zip', '.7z'],
- 'genesis': ['.md', '.bin', '.gen', '.zip', '.7z'],
- '3ds': ['.3ds', '.cia', '.app'],
- 'wiiu': ['.wua', '.rpx', '.wud', '.wux'],
- };
-
- static bool isRomFile(String platformSlug, String path) {
- final ext = p.extension(path).toLowerCase();
- if (ext.isEmpty) return false; // Folders are handled separately or skip
- final allowed = platformExtensions[platformSlug.toLowerCase()] ?? [];
- if (allowed.isEmpty) return true; // If no list, allow all (risky but fallback)
- return allowed.contains(ext);
- }
+ static const String _linuxPresetRootKey = 'emudeckRootPath';
final SharedPreferences _prefs;
late String romsRootPath;
@@ -125,58 +38,36 @@ class DirectoryService {
return home == '/home/deck' || io.Directory('/home/deck').existsSync();
}
- /// Detects EmuDeck root by scanning common mount points and internal home.
Future<String?> detectEmuDeckRoot() async {
final home = io.Platform.environment['HOME'] ?? '/home/deck';
-
- // 1. Check External SD / Removable Media FIRST (Most Steam Deck users prefer SD)
final mediaDir = io.Directory('/run/media');
if (await mediaDir.exists()) {
try {
final List<io.FileSystemEntity> users = await mediaDir.list().toList();
for (final userDir in users) {
if (userDir is! io.Directory) continue;
-
- // Check 1 level deep: /run/media/deck/Emulation
final candidate1 = p.join(userDir.path, 'Emulation');
if (await io.Directory(candidate1).exists()) return userDir.path;
-
- // Check 2 levels deep: /run/media/deck/LABEL/Emulation
final List<io.FileSystemEntity> mounts = await userDir.list().toList();
for (final mountDir in mounts) {
if (mountDir is! io.Directory) continue;
final candidate2 = p.join(mountDir.path, 'Emulation');
- if (await io.Directory(candidate2).exists()) {
- return mountDir.path;
- }
+ if (await io.Directory(candidate2).exists()) return mountDir.path;
}
}
} catch (_) {}
}
-
- // 2. Check Internal Home LAST
final internal = p.join(home, 'Emulation');
if (await io.Directory(internal).exists()) return home;
-
return null;
}
LinuxEnvironmentStrategy get activeLinuxEnvironment {
if (_linuxStrategy != null) return _linuxStrategy!;
-
switch (linuxSyncPreset) {
- case 'emudeck':
- _linuxStrategy = EmuDeckStrategy();
- break;
- case 'retrodeck':
- _linuxStrategy = RetroDeckStrategy();
- break;
- case 'auto':
- // For auto, we prefer EmuDeck if detected, then RetroDeck, then Native
- _linuxStrategy = EmuDeckStrategy(); // Defaulting to EmuDeck for now, logic refined in initialize
- break;
- default:
- _linuxStrategy = NativeLinuxStrategy();
+ case 'emudeck': _linuxStrategy = EmuDeckStrategy(); break;
+ case 'retrodeck': _linuxStrategy = RetroDeckStrategy(); break;
+ default: _linuxStrategy = NativeLinuxStrategy();
}
return _linuxStrategy!;
}
@@ -186,74 +77,48 @@ class DirectoryService {
linuxSyncPreset = _prefs.getString(_linuxSyncPresetKey) ?? 'default';
linuxPresetRootPath = _prefs.getString(_linuxPresetRootKey);
- // Auto-detection logic for Linux
if (defaultTargetPlatform == TargetPlatform.linux) {
- if (linuxSyncPreset == 'auto' || linuxSyncPreset == 'default') {
- // If we are in 'auto' or 'default', try to see if EmuDeck or RetroDeck is there.
- // BUT: If the user has already explicitly chosen a root, don't overwrite it with auto-detection.
- if (linuxPresetRootPath == null) {
- final detectedRoot = await detectEmuDeckRoot();
- if (detectedRoot != null) {
- linuxPresetRootPath = detectedRoot;
- linuxSyncPreset = 'emudeck';
- _linuxStrategy = EmuDeckStrategy();
- await _prefs.setString(_linuxSyncPresetKey, 'emudeck');
- await _prefs.setString(_linuxPresetRootKey, linuxPresetRootPath!);
- }
- } else if (linuxSyncPreset == 'default') {
- // If they are on 'default' (manual) but have an linuxPresetRootPath,
- // we stay on 'default' unless it's the very first run.
- }
-
- if (linuxSyncPreset == 'default' || linuxSyncPreset == 'auto') {
- // Check for RetroDeck
+ if ((linuxSyncPreset == 'auto' || linuxSyncPreset == 'default') && linuxPresetRootPath == null) {
+ final detectedRoot = await detectEmuDeckRoot();
+ if (detectedRoot != null) {
+ linuxPresetRootPath = detectedRoot;
+ linuxSyncPreset = 'emudeck';
+ await _prefs.setString(_linuxSyncPresetKey, 'emudeck');
+ await _prefs.setString(_linuxPresetRootKey, linuxPresetRootPath!);
+ } else {
final home = io.Platform.environment['HOME'] ?? '';
final retrodeckConfig = p.join(home, '.var', 'app', 'net.retrodeck.retrodeck');
if (await io.Directory(retrodeckConfig).exists()) {
linuxSyncPreset = 'retrodeck';
- _linuxStrategy = RetroDeckStrategy();
await _prefs.setString(_linuxSyncPresetKey, 'retrodeck');
}
}
}
}
- // Reset strategy to force re-instantiation with correct preset
_linuxStrategy = null;
-
final String defaultBase = await getDefaultBase();
final home = io.Platform.environment['HOME'] ?? '';
if (defaultTargetPlatform == TargetPlatform.linux) {
final customRoms = _prefs.getString(_romsRootPathKey);
final customEmus = _prefs.getString(_emulatorsRootPathKey);
-
romsRootPath = activeLinuxEnvironment.getRomsRoot(home, customRoms, linuxPresetRootPath);
emulatorsRootPath = activeLinuxEnvironment.getEmulatorsRoot(home, customEmus, linuxPresetRootPath);
} else {
romsRootPath = _prefs.getString(_romsRootPathKey) ?? p.join(defaultBase, 'ROMs');
- emulatorsRootPath =
- _prefs.getString(_emulatorsRootPathKey) ?? p.join(defaultBase, 'Emulators');
+ emulatorsRootPath = _prefs.getString(_emulatorsRootPathKey) ?? p.join(defaultBase, 'Emulators');
}
final romsStatus = await _ensureDirectoryExists(romsRootPath);
- if (romsStatus.hasError) {
- status = romsStatus;
- return status;
- }
-
+ if (romsStatus.hasError) return status = romsStatus;
final emusStatus = await _ensureDirectoryExists(emulatorsRootPath);
- if (emusStatus.hasError) {
- status = emusStatus;
- return status;
- }
+ if (emusStatus.hasError) return status = emusStatus;
loadEmulatorPathOverrides();
- status = const StorageStatus();
- return status;
+ return status = const StorageStatus();
} catch (e) {
- status = StorageStatus(error: StorageError.unknown, message: e.toString());
- return status;
+ return status = StorageStatus(error: StorageError.unknown, message: e.toString());
}
}
@@ -262,49 +127,46 @@ class DirectoryService {
return appSupport.path;
}
- Future<void> resetRomsRoot() async {
- await _prefs.remove(_romsRootPathKey);
- final base = await getDefaultBase();
- final home = io.Platform.environment['HOME'] ?? '';
+ Future<String?> resolveSevenZipPath() async {
+ final tempDir = await getTemporaryDirectory();
+ final String exeName = io.Platform.isWindows ? '7zr.exe' : '7zz';
+ final exeFile = io.File(p.join(tempDir.path, exeName));
- if (defaultTargetPlatform == TargetPlatform.linux) {
- romsRootPath = activeLinuxEnvironment.getRomsRoot(home, null, linuxPresetRootPath);
- } else {
- romsRootPath = '$base/ROMs';
- }
- status = await _ensureDirectoryExists(romsRootPath);
+ if (!await exeFile.exists()) {
+ try {
+ final data = await rootBundle.load('thirdparty/$exeName');
+ final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
+ await exeFile.writeAsBytes(bytes);
+ if (!io.Platform.isWindows) {
+ await Process.run('chmod', ['+x', exeFile.path]);
+ }
+ } catch (e) {
+ debugPrint('[DirectoryService] Failed to resolve 7zip: $e');
+ return null;
}
+ }
+ return exeFile.path;
+ }
- Future<void> resetEmulatorsRoot() async {
- await _prefs.remove(_emulatorsRootPathKey);
- final base = await getDefaultBase();
- final home = io.Platform.environment['HOME'] ?? '';
+ Future<void> resetRomsRoot() async {
+ await _prefs.remove(_romsRootPathKey);
+ await initialize();
+ }
+
+ Future<void> resetEmulatorsRoot() async {
+ await _prefs.remove(_emulatorsRootPathKey);
+ await initialize();
+ }
- if (defaultTargetPlatform == TargetPlatform.linux) {
- emulatorsRootPath = activeLinuxEnvironment.getEmulatorsRoot(home, null, linuxPresetRootPath);
- } else {
- emulatorsRootPath = p.join(base, 'Emulators');
- }
- status = await _ensureDirectoryExists(emulatorsRootPath);
- }
Future<void> setLinuxSyncPreset(String preset) async {
await _prefs.setString(_linuxSyncPresetKey, preset);
linuxSyncPreset = preset;
- // Re-initialize to update paths based on new preset
await initialize();
}
Future<void> setLinuxPresetRoot(String path) async {
- // If user picked the 'Emulation' folder or 'retrodeck' folder itself, we might want to handle it
- // But for now, we just store what they picked and strategies handle it.
- final name = p.basename(path).toLowerCase();
- if (name == 'emulation' || name == 'retrodeck') {
- // If they picked the subfolder instead of the root, we can optionally go up
- // but let's stay flexible for now.
- }
await _prefs.setString(_linuxPresetRootKey, path);
linuxPresetRootPath = path;
- // Re-initialize to update paths based on new root
await initialize();
}
@@ -313,9 +175,7 @@ class DirectoryService {
if (key.startsWith('emu_path_')) {
final emuId = key.replaceFirst('emu_path_', '');
final path = _prefs.getString(key);
- if (path != null) {
- _emulatorPathOverrides[emuId] = path;
- }
+ if (path != null) _emulatorPathOverrides[emuId] = path;
}
}
}
@@ -325,16 +185,11 @@ class DirectoryService {
_emulatorPathOverrides[emulatorId] = path;
}
- Future<String?> getEmulatorUrlOverride(String emulatorId) async {
- return _prefs.getString('emulator_url_override_$emulatorId');
- }
+ Future<String?> getEmulatorUrlOverride(String emulatorId) async => _prefs.getString('emulator_url_override_$emulatorId');
Future<void> setEmulatorUrlOverride(String emulatorId, String? url) async {
- if (url == null) {
- await _prefs.remove('emulator_url_override_$emulatorId');
- } else {
- await _prefs.setString('emulator_url_override_$emulatorId', url);
- }
+ if (url == null) await _prefs.remove('emulator_url_override_$emulatorId');
+ else await _prefs.setString('emulator_url_override_$emulatorId', url);
}
String? getEmulatorPathOverride(String emulatorId) => _emulatorPathOverrides[emulatorId];
@@ -343,42 +198,16 @@ class DirectoryService {
try {
final directory = Directory(path);
if (!await directory.exists()) {
- try {
- await directory.create(recursive: true);
- } catch (e) {
- if (e is io.FileSystemException &&
- (e.message.contains('Permission denied') || e.osError?.errorCode == 13)) {
- return StorageStatus(
- error: StorageError.permissionDenied,
- message: 'Permission denied to create or access directory.',
- failedPath: path,
- );
+ try { await directory.create(recursive: true); } catch (e) {
+ if (e is io.FileSystemException && (e.message.contains('Permission denied') || e.osError?.errorCode == 13)) {
+ return StorageStatus(error: StorageError.permissionDenied, message: 'Permission denied', failedPath: path);
}
- return StorageStatus(
- error: StorageError.pathNotFound,
- message: 'Path not found or drive disconnected.',
- failedPath: path,
- );
- }
- } else {
- // Double check if we can list it (verifies drive is actually mounted/accessible)
- try {
- await directory.list().first.catchError((_) => Directory(''));
- } catch (e) {
- return StorageStatus(
- error: StorageError.pathNotFound,
- message: 'Directory exists but is not accessible. Drive may be disconnected.',
- failedPath: path,
- );
+ return StorageStatus(error: StorageError.pathNotFound, message: 'Path not found', failedPath: path);
}
}
return const StorageStatus();
} catch (e) {
- return StorageStatus(
- error: StorageError.unknown,
- message: e.toString(),
- failedPath: path,
- );
+ return StorageStatus(error: StorageError.unknown, message: e.toString(), failedPath: path);
}
}
@@ -394,51 +223,18 @@ class DirectoryService {
status = await _ensureDirectoryExists(path);
}
- /// Checks if a platform's emulator natively supports ZIP or 7Z archives.
bool platformSupportsArchive(String? platformSlug) {
if (platformSlug == null) return false;
- final extensions = platformExtensions[platformSlug.toLowerCase()] ?? [];
+ final extensions = RomConstants.platformExtensions[platformSlug.toLowerCase()] ?? [];
return extensions.any((ext) => ext.toLowerCase() == '.zip' || ext.toLowerCase() == '.7z');
}
- Future<String> getRomsDirectory() async => romsRootPath;
-
- Future<Set<String>> getAllDownloadedFileNames() async {
- final Set<String> downloaded = {};
- try {
- final romsDir = Directory(await getRomsDirectory());
- if (!await romsDir.exists()) return downloaded;
-
- await for (final platformDir in romsDir.list()) {
- if (platformDir is! Directory) continue;
- await for (final entity in platformDir.list()) {
- final name = p.basename(entity.path);
- downloaded.add(name.toLowerCase());
- }
- }
- } catch (_) {}
- return downloaded;
+ Future<bool> isRomDownloaded(Game game) async {
+ final path = await findExistingRomPath(game);
+ return path != null;
}
- Future<Map<String, Set<String>>> getAllDownloadedFileNamesByPlatform() async {
- final Map<String, Set<String>> platformMap = {};
- try {
- final romsDir = Directory(await getRomsDirectory());
- if (!await romsDir.exists()) return platformMap;
-
- await for (final platformDir in romsDir.list()) {
- if (platformDir is! Directory) continue;
- final platformSlug = p.basename(platformDir.path);
- final Set<String> downloaded = {};
- await for (final entity in platformDir.list()) {
- final name = p.basename(entity.path);
- downloaded.add(name.toLowerCase());
- }
- platformMap[platformSlug] = downloaded;
- }
- } catch (_) {}
- return platformMap;
- }
+ Future<String> getRomsDirectory() async => romsRootPath;
Future<String> getRomDirectory(Game game) async {
final platformSlug = game.platformSlug ?? 'unknown';
@@ -453,253 +249,14 @@ class DirectoryService {
return p.join(romDir, fileName);
}
- /// Tries to find the actual ROM file on disk.
- /// First checks the exact path, then tries common extensions for the platform.
- /// Returns the found path or null if not found.
Future<String?> findExistingRomPath(Game game, {FileSystemIndex? index}) async {
final romDir = await getRomDirectory(game);
- final platformLower = game.platformSlug?.toLowerCase();
-
- debugPrint('[Matching] Searching for ${game.name} (Platform: $platformLower) in $romDir');
-
- // Names to check (in order of priority)
- final namesToCheck = <String>[];
- if (game.fsName != null) namesToCheck.add(game.fsName!);
- if (game.fileName != null) namesToCheck.add(game.fileName!);
-
- final sanitizedName = game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
- namesToCheck.add(sanitizedName);
-
- // 1. Check using Index if provided (Case-Insensitive & Fast)
- if (index != null && (index.rootPath == romDir || index.rootPath == p.join(romDir, 'roms'))) {
- for (final name in namesToCheck) {
- final lowerName = name.toLowerCase();
-
- // Try exact name match (files)
- if (index.files.containsKey(lowerName)) {
- debugPrint('[Matching] Index hit (file): $lowerName');
- return index.files[lowerName];
- }
-
- // Try name match (dirs)
- if (index.dirs.containsKey(lowerName)) {
- debugPrint('[Matching] Index hit (dir): $lowerName');
- final found = await _findMainRomInFolder(game, index.dirs[lowerName]!);
- if (found != null) return found;
- }
-
- // Try with extensions
- final extensions = platformExtensions[game.platformSlug?.toLowerCase()] ?? [];
- for (final ext in extensions) {
- final nameWithExt = lowerName.endsWith(ext.toLowerCase()) ? lowerName : '$lowerName${ext.toLowerCase()}';
- if (index.files.containsKey(nameWithExt)) return index.files[nameWithExt];
- }
- }
-
- // Fuzzy match in index (Last resort, only if name is very similar and not ambiguous)
- for (final name in namesToCheck) {
- final lowerName = name.toLowerCase();
- for (final entry in index.files.entries) {
- // Strict startsWith: only if the match is very close (e.g. adding a small suffix)
- if (entry.key.startsWith(lowerName) && entry.key.length < lowerName.length + 5 && !entry.key.endsWith('.part')) {
- // debugPrint('[Matching] Strict startsWith hit: ${entry.key}');
- return entry.value;
- }
- }
- }
- }
-
- // 2. Fallback to manual scanning (Legacy/Direct)
- final baseName = game.fsName ?? game.fileName ?? sanitizedName;
-
- // Check exact path first (Case-sensitive check)
- final exactPath = p.join(romDir, baseName);
- if (await File(exactPath).exists()) return p.absolute(exactPath);
-
- // Case-insensitive check by scanning parent directory manually if index not available
- final parentDir = Directory(romDir);
- if (await parentDir.exists()) {
- try {
- await for (final entity in parentDir.list()) {
- final fname = p.basename(entity.path);
- if (fname.toLowerCase() == baseName.toLowerCase()) {
- if (entity is File) return p.absolute(entity.path);
- if (entity is Directory) {
- final found = await _findMainRomInFolder(game, entity.path);
- if (found != null) return found;
- }
- }
- }
- } catch (_) {}
- }
-
- // 3. Search for multi-file folder (sanitized game name)
- final folderName = sanitizedName;
- final searchDirs = [romDir, p.join(romDir, 'roms')];
-
- for (final dirPath in searchDirs) {
- final pDir = Directory(dirPath);
- if (!await pDir.exists()) continue;
-
- // Check direct folder match (Case-insensitive)
- try {
- await for (final entity in pDir.list()) {
- if (entity is Directory) {
- final dName = p.basename(entity.path);
- if (dName.toLowerCase() == folderName.toLowerCase()) {
- final found = await _findMainRomInFolder(game, entity.path);
- if (found != null) return found;
- }
- }
- }
- } catch (_) {}
- }
-
- // 4. Try common extensions for this platform (Case-insensitive)
- final extensions = platformExtensions[platformLower] ?? [];
- for (final dirPath in searchDirs) {
- final pDir = Directory(dirPath);
- if (!await pDir.exists()) continue;
-
- try {
- final List<FileSystemEntity> entities = await pDir.list().toList();
- for (final ext in extensions) {
- for (final entity in entities) {
- if (entity is File) {
- final fname = p.basename(entity.path).toLowerCase();
- final target = '$baseName$ext'.toLowerCase();
- if (fname == target || fname == baseName.toLowerCase()) {
- return p.absolute(entity.path);
- }
- }
- }
- }
- } catch (_) {}
- }
-
- // 5. Scan directory for fuzzy file match
- for (final dirPath in searchDirs) {
- final pDir = Directory(dirPath);
- if (!await pDir.exists()) continue;
-
- try {
- await for (final entity in pDir.list()) {
- if (entity is File) {
- final fname = p.basename(entity.path).toLowerCase();
- final target = baseName.toLowerCase();
- if (fname == target || fname == '$target.iso' || fname == '$target.bin' || fname == '$target.pkg') {
- return p.absolute(entity.path);
- }
- }
- }
- } catch (_) {}
- }
-
- debugPrint('[Matching] No match found for ${game.name}');
- return null;
- }
-
- /// Finds the largest ROM-like file in a folder.
- Future<String?> _findMainRomInFolder(Game game, String folderPath) async {
- final platform = game.platformSlug?.toLowerCase() ?? '';
- final isFolderBased = ['windows', 'pc', 'win', 'ps3', 'switch', 'nintendo-switch'].contains(platform);
- if (isFolderBased) {
- // For folder-based platforms, we can often just return the folder path if it's a direct match
- // But we still try to find a "main" file inside first for better emulator compatibility
- }
-
- final extensions = platformExtensions[game.platformSlug?.toLowerCase()] ?? [];
-
- File? largestFile;
- int largestSize = 0;
-
- try {
- await for (final entity in Directory(folderPath).list(recursive: true)) {
- if (entity is File) {
- final ext = p.extension(entity.path).toLowerCase();
- // Filter by platform extensions if available, or just take any significant file
- if (extensions.isEmpty || extensions.contains(ext)) {
- final size = await entity.length();
- if (size > largestSize) {
- largestSize = size;
- largestFile = entity;
- }
- }
- }
- }
- } catch (_) {}
-
- if (largestFile != null) {
- return p.absolute(largestFile.path);
- }
-
- // Fallback for PS3/Switch folders that might not have a "known" extension but are valid
- if (isFolderBased) return p.absolute(folderPath);
-
- return null;
- }
-
- Future<String?> _getWindowsAppData() async {
- try {
- final result = await Process.run('cmd', ['/c', 'echo %APPDATA%'], runInShell: false);
- final path = result.stdout.toString().trim();
- if (path.isEmpty || path.contains('%APPDATA%')) return null;
- return path;
- } catch (e) {
- return null;
- }
- }
-
- Future<String?> resolveSevenZipPath() async {
- if (defaultTargetPlatform == TargetPlatform.windows) {
- final appData = await _getWindowsAppData();
- if (appData == null) return null;
-
- final dest = File('$appData\\Freegosy\\thirdparty\\7zr.exe');
- if (await dest.exists()) return dest.path;
-
- try {
- await dest.parent.create(recursive: true);
- final byteData = await rootBundle.load('thirdparty/7zr.exe');
- await dest.writeAsBytes(byteData.buffer.asUint8List());
- return dest.path;
- } catch (e) {
- return null;
- }
- } else if (defaultTargetPlatform == TargetPlatform.macOS) {
- final appSupport = await getApplicationSupportDirectory();
- final dest = File('${appSupport.path}/Freegosy/thirdparty/7zz');
- if (await dest.exists()) return dest.path;
-
- try {
- await dest.parent.create(recursive: true);
- final byteData = await rootBundle.load('thirdparty/7zz');
- await dest.writeAsBytes(byteData.buffer.asUint8List());
- await Process.run('chmod', ['+x', dest.path]);
- return dest.path;
- } catch (e) {
- 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;
- }
-
- return null;
+ return RomLookupService.findExistingRomPath(game, romDir, index: index);
}
Future<String> getEmulatorDirectory(String emulatorId) async {
final override = getEmulatorPathOverride(emulatorId);
if (override != null) return override;
-
final dirPath = p.join(emulatorsRootPath, emulatorId);
await _ensureDirectoryExists(dirPath);
return dirPath;
@@ -708,8 +265,6 @@ class DirectoryService {
Future<String> getEmulatorAppSupportDirectory(String emulatorName, {String? platformSlug}) async {
if (io.Platform.isMacOS) {
final appSupport = await getApplicationSupportDirectory();
- // On macOS, getApplicationSupportDirectory() returns ~/Library/Application Support/com.abduznik.freegosy
- // We want ~/Library/Application Support/emulatorName
return p.join(appSupport.parent.parent.path, 'Application Support', emulatorName);
} else if (io.Platform.isWindows) {
final appData = io.Platform.environment['APPDATA'] ?? '';
@@ -718,7 +273,7 @@ class DirectoryService {
final home = io.Platform.environment['HOME'] ?? '';
return activeLinuxEnvironment.getEmulatorAppSupportDirectory(home, emulatorName, linuxPresetRootPath, platformSlug: platformSlug);
}
- throw UnsupportedError('Platform not supported for save path resolution');
+ throw UnsupportedError('Platform not supported');
}
Future<String> getEmulatorBiosDirectory(String emulatorId, {String? platformSlug}) async {
@@ -726,305 +281,170 @@ class DirectoryService {
final home = io.Platform.environment['HOME'] ?? '';
return activeLinuxEnvironment.getBiosPath(home, linuxPresetRootPath);
}
+
final emuDir = await getEmulatorDirectory(emulatorId);
+
+ // RetroArch on Windows specifically looks for bios in its 'system' directory,
+ // which is usually next to the executable, but often inside a subfolder like 'RetroArch-Win64'
+ if (emulatorId == 'retroarch' && io.Platform.isWindows) {
+ final exePath = await findEmulatorExecutable('retroarch', 'RetroArch.exe');
+ if (exePath != null) {
+ final systemDir = p.join(io.File(exePath).parent.path, 'system');
+ await _ensureDirectoryExists(systemDir);
+ return systemDir;
+ }
+ }
+
final dirPath = p.join(emuDir, 'BIOS');
await _ensureDirectoryExists(dirPath);
return dirPath;
}
- Future<String> getEmulatorSystemDirectory(String emulatorId, {String? platformSlug}) async {
- return await getEmulatorBiosDirectory(emulatorId, platformSlug: platformSlug);
- }
+ Future<String> getEmulatorSystemDirectory(String emulatorId, {String? platformSlug}) async => getEmulatorBiosDirectory(emulatorId, platformSlug: platformSlug);
Future<void> deleteEmulator(String emulatorId) async {
final dirPath = await getEmulatorDirectory(emulatorId);
final directory = io.Directory(dirPath);
- if (await directory.exists()) {
- await directory.delete(recursive: true);
- }
+ if (await directory.exists()) await directory.delete(recursive: true);
}
- Future<String> getEmulatorExecutable(String emulatorId, String executableName) async {
- final emulatorDir = await getEmulatorDirectory(emulatorId);
- return '$emulatorDir/$executableName';
+ Future<void> deleteRom(Game game) async {
+ final path = await findExistingRomPath(game);
+ if (path != null) {
+ final file = io.File(path);
+ if (await file.exists()) await file.delete();
+ final dir = io.Directory(path);
+ if (await dir.exists()) await dir.delete(recursive: true);
+ }
}
Future<String?> findEmulatorExecutable(String emulatorId, String executableName) async {
final emulatorDir = await getEmulatorDirectory(emulatorId);
final dir = Directory(emulatorDir);
- if (!await dir.exists()) {
- return null;
- }
+ if (!await dir.exists()) return null;
- debugPrint("=== EMULATOR CHECK ===");
- debugPrint("Looking for: $executableName in $emulatorDir");
-
- // 1. Direct match (absolute or relative to emulatorDir)
+ // 1. Try direct path
final direct = File(p.join(emulatorDir, executableName));
- if (await direct.exists()) {
- return direct.path;
- }
+ if (await direct.exists()) return direct.path;
- // Try with .exe on Windows
+ // 2. Try with .exe extension on Windows
if (io.Platform.isWindows && !executableName.toLowerCase().endsWith('.exe')) {
final withExe = File(p.join(emulatorDir, '$executableName.exe'));
- if (await withExe.exists()) {
- return withExe.path;
- }
+ if (await withExe.exists()) return withExe.path;
}
+ // 3. Environment-specific logic (e.g., Linux Flatpaks)
if (io.Platform.isLinux) {
final envPath = await activeLinuxEnvironment.findExecutable(emulatorId, executableName, emulatorsRootPath, linuxPresetRootPath);
if (envPath != null) return envPath;
}
- if (executableName.contains('/')) {
- final parts = executableName.split('/');
- final firstPart = parts.first; // e.g. "PCSX2.app"
- final remaining = parts.sublist(1).join('/'); // e.g. "Contents/MacOS/PCSX2"
-
- // Recursive search for the bundle directory (max 3 levels)
- await for (final entity in dir.list(recursive: true)) {
- if (entity is Directory) {
- final dirName = p.basename(entity.path);
-
- // Exact match or fuzzy match for versioned .app bundles (e.g. PCSX2-v2.6.3.app matches PCSX2.app)
- bool matches = dirName.toLowerCase() == firstPart.toLowerCase();
- if (!matches && firstPart.toLowerCase().endsWith('.app') && dirName.toLowerCase().endsWith('.app')) {
- final stem = firstPart.substring(0, firstPart.length - 4).toLowerCase();
- if (dirName.toLowerCase().startsWith(stem)) {
- matches = true;
- }
- }
-
- if (matches) {
- debugPrint("[DirectoryService] Found matching .app bundle: $dirName at ${entity.path}");
- final sub = File(p.join(entity.path, remaining));
- if (await sub.exists()) {
- debugPrint("[DirectoryService] Found direct binary: ${sub.path}");
- return sub.path;
- }
-
- // Secondary check: search inside Contents/MacOS case-insensitively
- final macosDir = Directory(p.join(entity.path, 'Contents', 'MacOS'));
- if (await macosDir.exists()) {
- final binaryName = remaining.split('/').last.toLowerCase();
- debugPrint("[DirectoryService] Searching for binary '$binaryName' in ${macosDir.path}");
- await for (final subEntity in macosDir.list()) {
- if (subEntity is File) {
- final subName = p.basename(subEntity.path).toLowerCase();
- if (subName == binaryName) {
- debugPrint("[DirectoryService] Found binary via case-insensitive match: ${subEntity.path}");
- return subEntity.path;
- }
- }
- }
-
- // Third check: find any file in Contents/MacOS (fallback)
- await for (final subEntity in macosDir.list()) {
- if (subEntity is File) {
- final subName = p.basename(subEntity.path).toLowerCase();
- final stem = dirName.toLowerCase().replaceAll('.app', '');
- if (subName.contains(stem) || stem.contains(subName)) {
- debugPrint("[DirectoryService] Found binary via stem match: ${subEntity.path}");
- return subEntity.path;
- }
- }
- }
- // Last resort: return the first file in Contents/MacOS
- await for (final subEntity in macosDir.list()) {
- if (subEntity is File) {
- debugPrint("[DirectoryService] Using first binary found as last resort: ${subEntity.path}");
- return subEntity.path;
- }
- }
+ // 4. Handle nested structures (e.g., zip contains a folder like 'RetroArch-Win64/')
+ // Search up to 2 levels deep to find the executable
+ try {
+ final List<io.FileSystemEntity> entities = await dir.list(recursive: true).toList();
+ for (final entity in entities) {
+ if (entity is io.File) {
+ final fileName = p.basename(entity.path);
+ if (fileName.toLowerCase() == executableName.toLowerCase() ||
+ (io.Platform.isWindows && fileName.toLowerCase() == '${executableName.toLowerCase()}.exe')) {
+ // Check depth
+ final relative = p.relative(entity.path, from: emulatorDir);
+ final depth = relative.split(io.Platform.pathSeparator).length;
+ if (depth <= 3) { // Root/Subfolder/Executable.exe or Root/Executable.exe
+ return entity.path;
}
}
}
}
+ } catch (e) {
+ debugPrint('[DirectoryService] Error during recursive search: $e');
}
- // 3. Fallback: Recursive search for the binary (important for Windows "publish" subfolders)
- debugPrint("[DirectoryService] Starting recursive search for $executableName in $emulatorDir");
- int count = 0;
- await for (final entity in dir.list(recursive: true)) {
- count++;
- if (entity is File) {
- final fileName = p.basename(entity.path).toLowerCase();
- final searchName = executableName.toLowerCase();
-
- if (fileName == searchName) {
- debugPrint("[DirectoryService] Found binary via recursive search: ${entity.path}");
- return entity.path;
- }
-
- if (io.Platform.isWindows && !searchName.endsWith('.exe')) {
- if (fileName == '$searchName.exe') {
- debugPrint("[DirectoryService] Found binary via recursive search (with .exe): ${entity.path}");
- return entity.path;
- }
- }
- }
- }
- debugPrint("[DirectoryService] Recursive search finished. Scanned $count entities. Not found.");
-
- // Secondary check for core libraries if binary not found
- final ext = io.Platform.isMacOS ? '.dylib' : (io.Platform.isWindows ? '.dll' : '.so');
- await for (final entity in dir.list(recursive: true)) {
- if (entity is File) {
- final name = p.basename(entity.path).toLowerCase();
- if (name.endsWith(ext) && name.contains(emulatorId.toLowerCase())) {
- debugPrint("Found core library fallback: ${entity.path}");
- return entity.path;
+ // 5. Fallback for specifically nested paths in definitions
+ if (executableName.contains('/')) {
+ final parts = executableName.split('/');
+ final firstPart = parts.first;
+ try {
+ await for (final entity in dir.list(recursive: true)) {
+ if (entity is Directory && p.basename(entity.path) == firstPart) {
+ final fullPath = p.join(entity.path, parts.sublist(1).join('/'));
+ if (await File(fullPath).exists()) return fullPath;
+ }
}
- }
+ } catch (_) {}
}
-
+
return null;
}
- Future<bool> isEmulatorInstalled(String emulatorId, String executableName) async {
- final found = await findEmulatorExecutable(emulatorId, executableName);
- return found != null;
- }
-
- Future<bool> isRomDownloaded(Game game) async {
- final found = await findExistingRomPath(game);
- return found != null;
- }
-
- bool isEmuLaunchScript(String path) {
- return p.basename(path) == 'emu-launch.sh';
- }
-
Future<void> launchGame(Game game, String romPath, String emulatorId, String exePath, {List<String> args = const []}) async {
- debugPrint('[DirectoryService] Launching $emulatorId with $romPath');
if (io.Platform.isLinux) {
await activeLinuxEnvironment.launch(game, romPath, emulatorId, exePath, args: args);
- } else {
- final exeDir = io.File(exePath).parent.path;
- if (io.Platform.isWindows) {
- final absExe = p.absolute(exePath).replaceAll('/', '\\');
- final absRom = p.absolute(romPath).replaceAll('/', '\\');
- final absDir = p.absolute(exeDir).replaceAll('/', '\\');
-
- debugPrint('[DirectoryService] Windows Launch: $absExe ${args.join(' ')} $absRom (WorkingDir: $absDir)');
-
- await Process.start(
- absExe,
- [...args, absRom],
- mode: io.ProcessStartMode.detached,
- workingDirectory: absDir,
- runInShell: false,
- );
- } else if (io.Platform.isMacOS && exePath.contains('.app/')) {
- // Find the .app bundle path
- final parts = exePath.split('/');
- final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
- final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
-
- debugPrint('[DirectoryService] macOS App Launch: open -a "$appBundlePath" --args ${args.join(' ')} "$romPath"');
-
- // Launch via 'open' to ensure macOS environment handles the bundle correctly.
- // Arguments to the binary itself must come after '--args'.
- await Process.run('open', ['-a', appBundlePath, '--args', ...args, romPath]);
+ return;
+ }
+
+ final exeDir = io.File(exePath).parent.path;
+ if (io.Platform.isWindows) {
+ await io.Process.start(exePath, [...args, romPath], mode: io.ProcessStartMode.detached, workingDirectory: exeDir);
+ } else if (io.Platform.isMacOS) {
+ // For macOS, we might need 'open -a' or direct execution
+ if (exePath.contains('.app')) {
+ final appPath = exePath.substring(0, exePath.indexOf('.app') + 4);
+ await io.Process.run('open', ['-a', appPath, romPath, '--args', ...args]);
} else {
- debugPrint('[DirectoryService] Unix/macOS Binary Launch: $exePath ${args.join(' ')} $romPath');
- await Process.start(
- exePath,
- [...args, romPath],
- mode: io.ProcessStartMode.detached,
- );
+ await io.Process.start(exePath, [...args, romPath], mode: io.ProcessStartMode.detached, workingDirectory: exeDir);
}
}
}
- Future<Process?> launchGameWithHandle(Game game, String romPath, String emulatorId, String exePath, {List<String> args = const []}) async {
- debugPrint('[DirectoryService] Launching with handle: $emulatorId with $romPath');
+ Future<io.Process?> launchGameWithHandle(Game game, String romPath, String emulatorId, String exePath, {List<String> args = const []}) async {
if (io.Platform.isLinux) {
return await activeLinuxEnvironment.launchWithHandle(game, romPath, emulatorId, exePath, args: args);
- } else {
- final exeDir = io.File(exePath).parent.path;
- if (io.Platform.isWindows) {
- final absExe = p.absolute(exePath).replaceAll('/', '\\');
- final absRom = p.absolute(romPath).replaceAll('/', '\\');
- final absDir = p.absolute(exeDir).replaceAll('/', '\\');
-
- debugPrint('[DirectoryService] === WINDOWS PROCESS START ===');
- debugPrint('[DirectoryService] Raw Exe: $exePath');
- debugPrint('[DirectoryService] Raw Rom: $romPath');
- debugPrint('[DirectoryService] Abs Exe: $absExe');
- debugPrint('[DirectoryService] Abs Rom: $absRom');
- debugPrint('[DirectoryService] Working Dir: $absDir');
+ }
- final process = await Process.start(
- absExe,
- [...args, absRom],
- mode: io.ProcessStartMode.normal,
- workingDirectory: absDir,
- runInShell: false,
- );
-
- // IMPORTANT: Drain stdout/stderr to prevent the process from hanging when buffers are full.
- process.stdout.listen((_) {}, onDone: () {}, onError: (_) {});
- process.stderr.listen((_) {}, onDone: () {}, onError: (_) {});
-
- return process;
- } else if (io.Platform.isMacOS && exePath.contains('.app/')) {
- debugPrint('[DirectoryService] macOS App Handle Launch (via binary): $exePath ${args.join(' ')} $romPath');
- // Note: For handles, we stick to direct execution as 'open' doesn't return child PID.
- return await Process.start(
- exePath,
- [...args, romPath],
- mode: io.ProcessStartMode.normal,
- );
+ final exeDir = io.File(exePath).parent.path;
+ if (io.Platform.isWindows) {
+ return await io.Process.start(exePath, [...args, romPath], mode: io.ProcessStartMode.normal, workingDirectory: exeDir);
+ } else if (io.Platform.isMacOS) {
+ if (exePath.contains('.app')) {
+ // We can't easily get a handle with 'open', so we try direct execution if possible
+ return await io.Process.start(exePath, [...args, romPath], mode: io.ProcessStartMode.normal, workingDirectory: exeDir);
} else {
- debugPrint('[DirectoryService] Unix/macOS Binary Handle Launch: $exePath ${args.join(' ')} $romPath');
- return await Process.start(
- exePath,
- [...args, romPath],
- mode: io.ProcessStartMode.normal,
- );
+ return await io.Process.start(exePath, [...args, romPath], mode: io.ProcessStartMode.normal, workingDirectory: exeDir);
}
}
+ return null;
}
Future<void> launchStandalone(String emulatorId, String exePath, {List<String> args = const []}) async {
if (io.Platform.isLinux) {
await activeLinuxEnvironment.launchStandalone(emulatorId, exePath, args: args);
- } else {
- if (io.Platform.isMacOS) {
- // Find the .app bundle path
- final parts = exePath.split('/');
- final appIdx = parts.indexWhere((p) => p.endsWith('.app'));
- if (appIdx != -1) {
- final appBundlePath = parts.sublist(0, appIdx + 1).join('/');
- if (await Directory(appBundlePath).exists()) {
- await io.Process.run('open', [appBundlePath]);
- return;
- }
- }
- }
+ return;
+ }
- final exeDir = File(exePath).parent.path;
- if (io.Platform.isWindows) {
- final normalizedExe = exePath.replaceAll('/', '\\');
- final normalizedDir = exeDir.replaceAll('/', '\\');
- await Process.start(normalizedExe, args, mode: io.ProcessStartMode.detached, workingDirectory: normalizedDir);
+ final exeDir = io.File(exePath).parent.path;
+ if (io.Platform.isWindows) {
+ await io.Process.start(exePath, args, mode: io.ProcessStartMode.detached, workingDirectory: exeDir);
+ } else if (io.Platform.isMacOS) {
+ if (exePath.contains('.app')) {
+ final appPath = exePath.substring(0, exePath.indexOf('.app') + 4);
+ await io.Process.run('open', ['-a', appPath, '--args', ...args]);
} else {
- await Process.start(exePath, args, mode: io.ProcessStartMode.detached, workingDirectory: exeDir);
+ await io.Process.start(exePath, args, mode: io.ProcessStartMode.detached, workingDirectory: exeDir);
}
}
}
- Future<void> deleteRom(Game game) async {
- final path = await findExistingRomPath(game);
- if (path != null) {
- if (await File(path).exists()) {
- await File(path).delete();
- } else if (await Directory(path).exists()) {
- await Directory(path).delete(recursive: true);
- }
- }
+ Future<bool> isEmulatorInstalled(String emulatorId, String executableName) async {
+ final path = await findEmulatorExecutable(emulatorId, executableName);
+ return path != null;
+ }
+
+ static bool isRomFile(String platformSlug, String path) {
+ final ext = p.extension(path).toLowerCase();
+ if (ext.isEmpty) return false;
+ final validExtensions = RomConstants.platformExtensions[platformSlug.toLowerCase()] ?? [];
+ return validExtensions.any((v) => v.toLowerCase() == ext);
}
}
diff --git a/lib/core/storage/file_system_index.dart b/lib/core/storage/file_system_index.dart
new file mode 100644
index 0000000..72e65d9
--- /dev/null
+++ b/lib/core/storage/file_system_index.dart
@@ -0,0 +1,58 @@
+import 'dart:io' as io;
+import 'package:path/path.dart' as p;
+
+enum StorageError { none, pathNotFound, permissionDenied, unknown }
+
+class StorageStatus {
+ final StorageError error;
+ final String? message;
+ final String? failedPath;
+
+ const StorageStatus({this.error = StorageError.none, this.message, this.failedPath});
+
+ bool get hasError => error != StorageError.none;
+}
+
+class FileSystemIndex {
+ final String rootPath;
+ final Map<String, String> files; // lowercase name -> absolute path
+ final Map<String, String> dirs; // lowercase name -> absolute path
+ final Map<String, int> fileSizes; // absolute path -> size
+
+ FileSystemIndex({
+ required this.rootPath,
+ required this.files,
+ required this.dirs,
+ required this.fileSizes,
+ });
+
+ static Future<FileSystemIndex> build(String path) async {
+ final Map<String, String> files = {};
+ final Map<String, String> dirs = {};
+ final Map<String, int> fileSizes = {};
+
+ final rootDir = io.Directory(path);
+ if (await rootDir.exists()) {
+ try {
+ await for (final entity in rootDir.list(recursive: false)) {
+ final name = p.basename(entity.path).toLowerCase();
+ if (entity is io.File) {
+ files[name] = p.absolute(entity.path);
+ try {
+ fileSizes[p.absolute(entity.path)] = await entity.length();
+ } catch (_) {}
+ } else if (entity is io.Directory) {
+ dirs[name] = p.absolute(entity.path);
+ }
+ }
+ } catch (_) {}
+ }
+
+ return FileSystemIndex(
+ rootPath: path,
+ files: files,
+ dirs: dirs,
+ fileSizes: fileSizes,
+ );
+ }
+}
diff --git a/lib/core/storage/rom_lookup_service.dart b/lib/core/storage/rom_lookup_service.dart
new file mode 100644
index 0000000..048c9af
--- /dev/null
+++ b/lib/core/storage/rom_lookup_service.dart
@@ -0,0 +1,262 @@
+import 'dart:io' as io;
+import 'package:flutter/foundation.dart';
+import 'package:path/path.dart' as p;
+import '../romm/romm_models.dart';
+import '../romm/rom_constants.dart';
+import 'file_system_index.dart';
+
+class RomLookupService {
+ /// Tries to find the actual ROM file on disk.
+ /// First checks the exact path, then tries common extensions for the platform.
+ /// Returns the found path or null if not found.
+ static Future<String?> findExistingRomPath(
+ Game game,
+ String romDir, {
+ FileSystemIndex? index,
+ }) async {
+ final platformLower = game.platformSlug?.toLowerCase();
+
+ debugPrint('[Matching] Searching for ${game.name} (Platform: $platformLower) in $romDir');
+
+ // Names to check (in order of priority)
+ final namesToCheck = <String>[];
+ if (game.fsName != null) namesToCheck.add(game.fsName!);
+ if (game.fileName != null) namesToCheck.add(game.fileName!);
+
+ final sanitizedName = game.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
+ namesToCheck.add(sanitizedName);
+
+ // 1. Check using Index if provided (Case-Insensitive & Fast)
+ if (index != null && (index.rootPath == romDir || index.rootPath == p.join(romDir, 'roms'))) {
+ for (final name in namesToCheck) {
+ final lowerName = name.toLowerCase();
+
+ // Try exact name match (files)
+ if (index.files.containsKey(lowerName)) {
+ debugPrint('[Matching] Index hit (file): $lowerName');
+ return index.files[lowerName];
+ }
+
+ // Try name match (dirs)
+ if (index.dirs.containsKey(lowerName)) {
+ debugPrint('[Matching] Index hit (dir): $lowerName');
+ final found = await findMainRomInFolder(game, index.dirs[lowerName]!);
+ if (found != null) return found;
+ }
+
+ // Try with extensions
+ final extensions = RomConstants.platformExtensions[game.platformSlug?.toLowerCase()] ?? [];
+ for (final ext in extensions) {
+ final nameWithExt = lowerName.endsWith(ext.toLowerCase()) ? lowerName : '$lowerName${ext.toLowerCase()}';
+ if (index.files.containsKey(nameWithExt)) return index.files[nameWithExt];
+ }
+ }
+
+ // Fuzzy match in index (Last resort, only if name is very similar and not ambiguous)
+ for (final name in namesToCheck) {
+ final lowerName = name.toLowerCase();
+ for (final entry in index.files.entries) {
+ if (entry.key.startsWith(lowerName) && entry.key.length < lowerName.length + 5 && !entry.key.endsWith('.part')) {
+ return entry.value;
+ }
+ }
+ }
+ }
+
+ // 2. Fallback to manual scanning (Legacy/Direct)
+ final baseName = game.fsName ?? game.fileName ?? sanitizedName;
+
+ // Check exact path first
+ final exactPath = p.join(romDir, baseName);
+ if (await io.File(exactPath).exists()) return p.absolute(exactPath);
+
+ // Case-insensitive check by scanning parent directory manually
+ final parentDir = io.Directory(romDir);
+ if (await parentDir.exists()) {
+ try {
+ await for (final entity in parentDir.list()) {
+ final fname = p.basename(entity.path);
+ if (fname.toLowerCase() == baseName.toLowerCase()) {
+ if (entity is io.File) return p.absolute(entity.path);
+ if (entity is io.Directory) {
+ final found = await findMainRomInFolder(game, entity.path);
+ if (found != null) return found;
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ // 3. Search for multi-file folder
+ final folderName = sanitizedName;
+ final searchDirs = [romDir, p.join(romDir, 'roms')];
+
+ for (final dirPath in searchDirs) {
+ final pDir = io.Directory(dirPath);
+ if (!await pDir.exists()) continue;
+
+ try {
+ await for (final entity in pDir.list()) {
+ if (entity is io.Directory) {
+ final dName = p.basename(entity.path);
+ if (dName.toLowerCase() == folderName.toLowerCase()) {
+ final found = await findMainRomInFolder(game, entity.path);
+ if (found != null) return found;
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ // 4. Try common extensions for this platform
+ final extensions = RomConstants.platformExtensions[platformLower] ?? [];
+ for (final dirPath in searchDirs) {
+ final pDir = io.Directory(dirPath);
+ if (!await pDir.exists()) continue;
+
+ try {
+ final List<io.FileSystemEntity> entities = await pDir.list().toList();
+ for (final ext in extensions) {
+ for (final entity in entities) {
+ if (entity is io.File) {
+ final fname = p.basename(entity.path).toLowerCase();
+ final target = '$baseName$ext'.toLowerCase();
+ if (fname == target || fname == baseName.toLowerCase()) {
+ return p.absolute(entity.path);
+ }
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ // 5. Scan directory for fuzzy file match
+ for (final dirPath in searchDirs) {
+ final pDir = io.Directory(dirPath);
+ if (!await pDir.exists()) continue;
+
+ try {
+ await for (final entity in pDir.list()) {
+ if (entity is io.File) {
+ final fname = p.basename(entity.path).toLowerCase();
+ final target = baseName.toLowerCase();
+ if (fname == target || fname == '$target.iso' || fname == '$target.bin' || fname == '$target.pkg') {
+ return p.absolute(entity.path);
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ debugPrint('[Matching] No match found for ${game.name}');
+ return null;
+ }
+
+ /// Finds the largest ROM-like file in a folder.
+ static Future<String?> findMainRomInFolder(Game game, String folderPath) async {
+ final platform = game.platformSlug?.toLowerCase() ?? '';
+ final isFolderBased = ['windows', 'pc', 'win', 'ps3', 'switch', 'nintendo-switch'].contains(platform);
+
+ final extensions = RomConstants.platformExtensions[game.platformSlug?.toLowerCase()] ?? [];
+
+ io.File? largestFile;
+ int largestSize = 0;
+
+ try {
+ await for (final entity in io.Directory(folderPath).list(recursive: true)) {
+ if (entity is io.File) {
+ final ext = p.extension(entity.path).toLowerCase();
+ if (extensions.isEmpty || extensions.contains(ext)) {
+ final size = await entity.length();
+ if (size > largestSize) {
+ largestSize = size;
+ largestFile = entity;
+ }
+ }
+ }
+ }
+ } catch (_) {}
+
+ if (largestFile != null) {
+ return p.absolute(largestFile.path);
+ }
+
+ if (isFolderBased) return p.absolute(folderPath);
+
+ return null;
+ }
+
+ /// Fuzzy resolver specifically for platform strategies (like Eden).
+ static Future<String?> resolveFuzzyRomFile(String romPath, List<String> allowedExtensions) async {
+ if (io.File(romPath).existsSync()) return romPath;
+
+ for (final ext in allowedExtensions) {
+ final withExt = '$romPath$ext';
+ if (io.File(withExt).existsSync()) return withExt;
+ }
+
+ if (io.Directory(romPath).existsSync()) {
+ return _findLargestFileByExt(romPath, allowedExtensions);
+ }
+
+ final parentPath = p.dirname(romPath);
+ final parentDir = io.Directory(parentPath);
+ if (!parentDir.existsSync()) return null;
+
+ final baseName = p.basenameWithoutExtension(romPath);
+ final searchTokens = _tokenize(baseName);
+ if (searchTokens.isEmpty) return null;
+
+ io.File? bestMatch;
+ int bestScore = 0;
+
+ for (final entity in parentDir.listSync()) {
+ if (entity is! io.File) continue;
+ final fileName = p.basename(entity.path).toLowerCase();
+ if (!allowedExtensions.any((ext) => fileName.endsWith(ext))) continue;
+
+ final fileTokens = _tokenize(p.basenameWithoutExtension(entity.path));
+ int score = 0;
+ for (final token in searchTokens) {
+ if (fileTokens.contains(token)) score++;
+ }
+
+ if (score > bestScore && score >= (searchTokens.length / 2).ceil()) {
+ bestScore = score;
+ bestMatch = entity;
+ }
+ }
+
+ return bestMatch?.path;
+ }
+
+ static String? _findLargestFileByExt(String dirPath, List<String> extensions) {
+ io.File? largest;
+ int maxSize = -1;
+ try {
+ for (final entity in io.Directory(dirPath).listSync(recursive: true)) {
+ if (entity is! io.File) continue;
+ final name = entity.path.toLowerCase();
+ if (!extensions.any((ext) => name.endsWith(ext))) continue;
+ final size = entity.lengthSync();
+ if (size > maxSize) {
+ maxSize = size;
+ largest = entity;
+ }
+ }
+ } catch (_) {}
+ return largest?.path;
+ }
+
+ static Set<String> _tokenize(String input) {
+ return input
+ .toLowerCase()
+ .replaceAll(RegExp(r'\[[^\]]*\]'), '')
+ .replaceAll(RegExp(r'\([^)]*\)'), '')
+ .replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
+ .trim()
+ .split(RegExp(r'\s+'))
+ .where((t) => t.length > 1)
+ .toSet();
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 04119c2..9518c4d 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -13,6 +14,38 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
LoggerService.init();
+
+ LicenseRegistry.addLicense(() {
+ return Stream<LicenseEntry>.fromIterable([
+ const LicenseEntryWithLineBreaks(
+ ['Freegosy'],
+ '''
+MIT License
+
+Copyright (c) 2026 abduznik
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+''',
+ ),
+ ]);
+ });
+
Hive.registerAdapter(BackupEntryAdapter());
await Hive.initFlutter();
await Hive.openBox<List>('freegosy_backups');
diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart
index ab7e453..b356be7 100644
--- a/lib/providers/download_provider.dart
+++ b/lib/providers/download_provider.dart
@@ -47,7 +47,8 @@ final downloadServiceProvider = FutureProvider<DownloadService?>((ref) async {
final emulatorDownloadServiceProvider =
FutureProvider<EmulatorDownloadService?>((ref) async {
final directoryService = await ref.watch(directoryServiceProvider.future);
- if (directoryService == null) return null;
+ final strategyRegistry = await ref.watch(strategyRegistryProvider.future);
+ if (directoryService == null || strategyRegistry == null) return null;
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 60),
@@ -71,6 +72,7 @@ final emulatorDownloadServiceProvider =
dio,
directoryService,
ExtractionService(directoryService),
+ strategyRegistry,
);
});
diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart
index 36cad4b..2cbfadb 100644
--- a/lib/providers/library_provider.dart
+++ b/lib/providers/library_provider.dart
@@ -44,6 +44,7 @@ final columnCountProvider = createPersistentProvider<int>('column_count', 6);
final cardSpacingProvider = createPersistentProvider<double>('card_spacing', 12.0);
final showTitleProvider = createPersistentProvider<bool>('show_title', true);
final activePresetProvider = createPersistentProvider<String>('active_preset', 'windows_best');
+final libraryHeaderTitleModeProvider = createPersistentProvider<String>('library_header_title_mode', 'daily');
// Legacy compatibility - redirects for loaders (no longer needed but kept for minimal breaking changes)
final cardAspectRatioLoaderProvider = FutureProvider<void>((ref) async {});
diff --git a/lib/providers/paginated_games_provider.dart b/lib/providers/paginated_games_provider.dart
index 456b4af..8726e8f 100644
--- a/lib/providers/paginated_games_provider.dart
+++ b/lib/providers/paginated_games_provider.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/romm/romm_models.dart';
import 'romm_provider.dart';
@@ -121,15 +122,17 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
_currentPlatformId = platformId;
_currentSearch = search;
final key = _key(platformId, search);
-
+
// Serve from memory cache immediately if available
if (_cache.containsKey(key)) {
+ final cachedList = _cache[key]!;
state = PaginatedGamesState(
- games: _cache[key]!,
- total: _totals[key] ?? _cache[key]!.length,
+ games: cachedList,
+ total: _totals[key] ?? cachedList.length,
hasMore: (_offsets[key] ?? 0) < (_totals[key] ?? 0),
isLoading: false,
);
+ debugPrint('📂 [Library-Fetch] Served ${cachedList.length} games from memory cache for platformId: $platformId');
// Background refresh of first page only
_backgroundRefresh(platformId: platformId, search: search, key: key);
return;
@@ -153,6 +156,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
hasMore: true,
isLoading: false,
);
+ debugPrint('📂 [Library-Fetch] Served ${offline.length} games from persistent SQLite cache for platformId: $platformId');
_backgroundRefresh(platformId: platformId, search: search, key: key);
return;
}
@@ -163,12 +167,13 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
final service = _ref.read(rommServiceProvider);
if (service == null) {
- // Fallback to offline cache
+ debugPrint('📂 [Library-Fetch] Offline fallback (Service is null) for platformId: $platformId');
await _loadOffline(platformId, search);
return;
}
try {
+ debugPrint('📂 [Library-Fetch] Fetching initial games page from RomM server for platformId: $platformId');
// SPECIAL CASE: If filtering for 'Downloaded Only', we want it to be "instant"
// and show everything we've identified on disk.
if (_activeFilters.downloadedOnly) {
@@ -198,6 +203,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
hasMore: false,
isLoading: false,
);
+ debugPrint('📂 [Library-Fetch] Served ${filtered.length} locally filtered downloaded-only games for platformId: $platformId');
return;
}
}
@@ -235,6 +241,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
hasMore: result.games.length < result.total,
isLoading: false,
);
+ debugPrint('📂 [Library-Fetch] RomM server fetched successfully: ${filteredGames.length} games for platformId: $platformId (Total matching: ${result.total})');
// Persist results for offline use
final cacheService = _ref.read(metadataCacheServiceProvider).value;
@@ -242,6 +249,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
await cacheService.saveGames(result.games);
}
} catch (e) {
+ debugPrint('📂 [Library-Fetch] RomM server fetch failed: $e. Falling back to offline.');
// On error, also try fallback
await _loadOffline(platformId, search, error: e.toString());
}
@@ -281,6 +289,7 @@ class PaginatedGamesNotifier extends StateNotifier<PaginatedGamesState> {
isLoading: false,
error: error != null ? 'Offline Mode (Server: $error)' : 'Offline Mode',
);
+ debugPrint('📂 [Library-Fetch] Loaded offline fallback with ${filteredOffline.length} games for platformId: $platformId');
}
Future<void> _backgroundRefresh({
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 727698e..70851f8 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freegosy/core/storage/directory_service.dart';
+import 'package:freegosy/core/storage/file_system_index.dart';
import 'package:freegosy/core/romm/romm_models.dart';
import 'package:freegosy/core/romm/romm_service.dart';
import 'package:freegosy/core/emulator/strategy_registry.dart';
diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart
new file mode 100644
index 0000000..fbb4239
--- /dev/null
+++ b/lib/providers/theme_provider.dart
@@ -0,0 +1,226 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'shared_prefs_provider.dart';
+
+enum ThemePreset {
+ darkDefault,
+ light,
+ muscular,
+ feminine,
+ neonCyan,
+ oledBlack,
+}
+
+extension ThemePresetExtension on ThemePreset {
+ String get displayName {
+ switch (this) {
+ case ThemePreset.darkDefault:
+ return 'Default Dark';
+ case ThemePreset.light:
+ return 'Light Mode';
+ case ThemePreset.muscular:
+ return 'Crimson';
+ case ThemePreset.feminine:
+ return 'Rose Gold';
+ case ThemePreset.neonCyan:
+ return 'Neon Cyan';
+ case ThemePreset.oledBlack:
+ return 'OLED Pitch Black';
+ }
+ }
+}
+
+class ThemeNotifier extends StateNotifier<ThemePreset> {
+ final SharedPreferences prefs;
+ static const _key = 'theme_preset';
+
+ ThemeNotifier(this.prefs) : super(_loadTheme(prefs));
+
+ static ThemePreset _loadTheme(SharedPreferences prefs) {
+ final name = prefs.getString(_key);
+ if (name != null) {
+ return ThemePreset.values.firstWhere(
+ (e) => e.name == name,
+ orElse: () => ThemePreset.darkDefault,
+ );
+ }
+ return ThemePreset.darkDefault;
+ }
+
+ void setTheme(ThemePreset preset) {
+ state = preset;
+ prefs.setString(_key, preset.name);
+ }
+}
+
+final themeProvider = StateNotifierProvider<ThemeNotifier, ThemePreset>((ref) {
+ final prefs = ref.watch(sharedPreferencesProvider);
+ return ThemeNotifier(prefs);
+});
+
+ThemeData getThemeData(ThemePreset preset) {
+ switch (preset) {
+ case ThemePreset.light:
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.deepPurple,
+ brightness: Brightness.light,
+ surface: const Color(0xFFF0F0F0),
+ ),
+ scaffoldBackgroundColor: const Color(0xFFFFFFFF),
+ cardTheme: const CardThemeData(color: Color(0xFFF8F8F8), elevation: 2),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Color(0xFFFFFFFF),
+ foregroundColor: Colors.black,
+ elevation: 0,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: const Color(0xFFF8F8F8),
+ indicatorColor: Colors.deepPurple.withValues(alpha: 0.2),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: const Color(0xFFF8F8F8),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.deepPurple.shade300)),
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.deepPurple.shade200)),
+ ),
+ );
+
+ case ThemePreset.muscular:
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.redAccent,
+ brightness: Brightness.dark,
+ surface: const Color(0xFF1a0000),
+ ),
+ scaffoldBackgroundColor: const Color(0xFF0a0000),
+ cardTheme: const CardThemeData(color: Color(0xFF1a0000), elevation: 2),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Color(0xFF0a0000),
+ foregroundColor: Colors.white,
+ elevation: 0,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: const Color(0xFF1a0000),
+ indicatorColor: Colors.redAccent.withValues(alpha: 0.3),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: const Color(0xFF1a0000),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.red.shade800)),
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.red.shade900)),
+ ),
+ );
+
+ case ThemePreset.feminine:
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.pinkAccent,
+ brightness: Brightness.dark,
+ surface: const Color(0xFF2a111a),
+ ),
+ scaffoldBackgroundColor: const Color(0xFF1c0a11),
+ cardTheme: const CardThemeData(color: Color(0xFF2a111a), elevation: 2),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Color(0xFF1c0a11),
+ foregroundColor: Colors.white,
+ elevation: 0,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: const Color(0xFF2a111a),
+ indicatorColor: Colors.pinkAccent.withValues(alpha: 0.3),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: const Color(0xFF2a111a),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.pink.shade800)),
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.pink.shade900)),
+ ),
+ );
+
+ case ThemePreset.neonCyan:
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.cyanAccent,
+ brightness: Brightness.dark,
+ surface: const Color(0xFF001a1a),
+ ),
+ scaffoldBackgroundColor: const Color(0xFF000d0d),
+ cardTheme: const CardThemeData(color: Color(0xFF001a1a), elevation: 2),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Color(0xFF000d0d),
+ foregroundColor: Colors.white,
+ elevation: 0,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: const Color(0xFF001a1a),
+ indicatorColor: Colors.cyanAccent.withValues(alpha: 0.3),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: const Color(0xFF001a1a),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.cyan.shade800)),
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.cyan.shade900)),
+ ),
+ );
+
+ case ThemePreset.oledBlack:
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.grey,
+ brightness: Brightness.dark,
+ surface: const Color(0xFF000000),
+ ),
+ scaffoldBackgroundColor: const Color(0xFF000000),
+ cardTheme: const CardThemeData(color: Color(0xFF050505), elevation: 2),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Color(0xFF000000),
+ foregroundColor: Colors.white,
+ elevation: 0,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: const Color(0xFF050505),
+ indicatorColor: Colors.grey.shade800,
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: const Color(0xFF000000),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade800)),
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade900)),
+ ),
+ );
+
+ case ThemePreset.darkDefault:
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.deepPurple,
+ brightness: Brightness.dark,
+ surface: const Color(0xFF1a1a1a),
+ ),
+ scaffoldBackgroundColor: const Color(0xFF0f0f0f),
+ cardTheme: const CardThemeData(color: Color(0xFF1a1a1a), elevation: 2),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: Color(0xFF0f0f0f),
+ foregroundColor: Colors.white,
+ elevation: 0,
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: const Color(0xFF1a1a1a),
+ indicatorColor: Colors.deepPurple.withValues(alpha: 0.3),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: const Color(0xFF1a1a1a),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.deepPurple.shade800)),
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.deepPurple.shade900)),
+ ),
+ );
+ }
+}
diff --git a/lib/providers/ui_provider.dart b/lib/providers/ui_provider.dart
index 101ad97..df2655a 100644
--- a/lib/providers/ui_provider.dart
+++ b/lib/providers/ui_provider.dart
@@ -1,3 +1,9 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+enum InputMode { mouse, gamepad, keyboard }
+
final currentTabIndexProvider = StateProvider<int>((ref) => 0);
+final inputModeProvider = StateProvider<InputMode>((ref) => InputMode.mouse);
+final focusedActionProvider = StateProvider<VoidCallback?>((ref) => null);
+final navigationLockedProvider = StateProvider<bool>((ref) => false);
diff --git a/lib/ui/screens/download_screen.dart b/lib/ui/screens/download_screen.dart
index 5c1629d..6017d21 100644
--- a/lib/ui/screens/download_screen.dart
+++ b/lib/ui/screens/download_screen.dart
@@ -2,24 +2,57 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/download_provider.dart';
import '../widgets/download_progress_card.dart';
+import '../widgets/focus_effect_wrapper.dart';
class DownloadScreen extends ConsumerWidget {
const DownloadScreen({super.key});
Future<bool> _showCancelConfirmation(BuildContext context, String gameName) async {
+ final theme = Theme.of(context);
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: const Text('Cancel Download'),
content: Text('Are you sure you want to cancel downloading $gameName? This will delete the partial file.'),
actions: [
- TextButton(
- onPressed: () => Navigator.pop(context, false),
- child: const Text('Keep'),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, false),
+ borderRadius: 16.0,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25),
+ border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text(
+ 'Keep',
+ style: TextStyle(color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold),
+ ),
+ ),
),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Cancel Download', style: TextStyle(color: Colors.red)),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, true),
+ borderRadius: 16.0,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: theme.colorScheme.errorContainer.withValues(alpha: 0.2),
+ border: Border.all(color: theme.colorScheme.error.withValues(alpha: 0.3)),
+ ),
+ child: Text(
+ 'Cancel Download',
+ style: TextStyle(
+ color: theme.colorScheme.error,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
),
],
),
diff --git a/lib/ui/screens/game_detail_screen.dart b/lib/ui/screens/game_detail_screen.dart
index 0b48c2f..1e5af56 100644
--- a/lib/ui/screens/game_detail_screen.dart
+++ b/lib/ui/screens/game_detail_screen.dart
@@ -14,6 +14,17 @@ import '../../providers/shared_prefs_provider.dart';
import '../widgets/screenshot_gallery_dialog.dart';
import '../widgets/download_progress_indicator.dart';
import '../widgets/backup_history_sheet.dart';
+import '../widgets/game_detail/game_action_button.dart';
+import '../widgets/game_detail/game_metadata_chip.dart';
+import '../widgets/game_detail/game_details_grid.dart';
+import '../widgets/game_detail/game_notes_section.dart';
+import '../widgets/game_detail/game_personal_section.dart';
+import '../widgets/focus_effect_wrapper.dart';
+import '../widgets/controller_hints_bar.dart';
+import '../../providers/ui_provider.dart';
+import '../../core/input/input_action_bus.dart';
+import '../../core/input/gamepad_service.dart';
+import 'dart:async';
class GameDetailScreen extends ConsumerStatefulWidget {
final Game game;
@@ -51,7 +62,21 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
late int _rating;
late String? _status;
late int _completion;
- bool _isSaving = false;
+ bool _isSaving = false;
+ bool _adjustingRating = false;
+ bool _adjustingCompletion = false;
+ bool _justEnteredRating = false;
+ bool _justEnteredCompletion = false;
+ bool _isAddingNote = false;
+ StreamSubscription<GameAction>? _inputSub;
+ final FocusNode _focusNode = FocusNode();
+ late ProviderContainer _container;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _container = ProviderScope.containerOf(context);
+ }
@override
void initState() {
@@ -60,17 +85,107 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
_isDownloaded = widget.isDownloaded;
_syncStateWithGame(_currentGame);
_checkDownloadStatus();
- // Targeted Lazy Sync: Ensure this game is mapped if it exists on disk
_lazySync();
- // Initial refresh to get latest notes and status
_refreshGame();
+
+ // Action Bus: Listen for Back command regardless of focus state
+ _inputSub = inputActionBus.stream.listen((action) {
+ if (mounted) {
+ if (_adjustingCompletion) {
+ if (_justEnteredCompletion) {
+ _justEnteredCompletion = false;
+ return;
+ }
+ if (action == GameAction.left) {
+ setState(() {
+ _completion = (_completion - 5).clamp(0, 100);
+ });
+ } else if (action == GameAction.right) {
+ setState(() {
+ _completion = (_completion + 5).clamp(0, 100);
+ });
+ } else if (action == GameAction.confirm || action == GameAction.back) {
+ _toggleAdjustingCompletion();
+ }
+ return;
+ }
+
+ if (_adjustingRating) {
+ if (_justEnteredRating) {
+ _justEnteredRating = false;
+ return;
+ }
+ if (action == GameAction.left) {
+ setState(() {
+ _rating = (_rating - 1).clamp(0, 10);
+ });
+ } else if (action == GameAction.right) {
+ setState(() {
+ _rating = (_rating + 1).clamp(0, 10);
+ });
+ } else if (action == GameAction.confirm || action == GameAction.back) {
+ _toggleAdjustingRating();
+ }
+ return;
+ }
+
+ if (action == GameAction.back) {
+ if (Navigator.of(context).canPop()) {
+ Navigator.of(context).pop();
+ }
+ }
+ }
+ });
+
+ // Autofocus: Ensure the screen is ready for input on open
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) {
+ _focusNode.requestFocus();
+ }
+ });
+ }
+
+ void _toggleAdjustingRating() {
+ setState(() {
+ _adjustingRating = !_adjustingRating;
+ _adjustingCompletion = false;
+ if (_adjustingRating) {
+ _justEnteredRating = true;
+ }
+ ref.read(navigationLockedProvider.notifier).state = _adjustingRating;
+ });
+ }
+
+ void _toggleAdjustingCompletion() {
+ setState(() {
+ _adjustingCompletion = !_adjustingCompletion;
+ _adjustingRating = false;
+ if (_adjustingCompletion) {
+ _justEnteredCompletion = true;
+ }
+ ref.read(navigationLockedProvider.notifier).state = _adjustingCompletion;
+ });
+ }
+
+ @override
+ void dispose() {
+ _inputSub?.cancel();
+ _focusNode.dispose();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ try {
+ _container.read(navigationLockedProvider.notifier).state = false;
+ } catch (_) {
+ // Container already disposed (e.g. during test teardown), safe to ignore!
+ }
+ });
+ super.dispose();
}
Future<void> _lazySync() async {
final scanner = ref.read(romScannerServiceProvider);
if (scanner != null) {
await scanner.syncSingleGame(_currentGame);
- // Re-check status after sync to update the "Play" button if found
+ if (!mounted) return;
_checkDownloadStatus();
}
}
@@ -84,112 +199,178 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
}
Future<void> _checkDownloadStatus() async {
+ if (!mounted) return;
final ds = ref.read(directoryServiceProvider).value;
if (ds != null) {
final exists = await ds.isRomDownloaded(_currentGame);
- if (mounted) {
- setState(() => _isDownloaded = exists);
- }
+ if (mounted) setState(() => _isDownloaded = exists);
}
}
Future<void> _refreshGame() async {
- if (widget.rommService == null) return;
+ if (!mounted || widget.rommService == null) return;
try {
final updated = await widget.rommService!.getGame(_currentGame.id);
- if (updated != null && mounted) {
+ if (!mounted) return;
+ if (updated != null) {
setState(() {
_currentGame = updated;
_syncStateWithGame(updated);
});
_checkDownloadStatus();
-
- // Update persistent cache so the list view and offline mode have the latest details
- final cacheService = ref.read(metadataCacheServiceProvider).value;
- if (cacheService != null) {
- await cacheService.saveGames([updated]);
- }
+ final cacheService = ref.read(metadataCacheServiceProvider).asData?.value;
+ if (cacheService != null) await cacheService.saveGames([updated]);
}
} catch (_) {}
}
Future<void> _addNote() async {
+ if (_isAddingNote) return;
+ _isAddingNote = true;
+
final titleController = TextEditingController();
final contentController = TextEditingController();
-
- final result = await showDialog<bool>(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Add Note'),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- TextField(
- controller: titleController,
- decoration: const InputDecoration(labelText: 'Title'),
+ bool? result;
+
+ try {
+ result = await showDialog<bool>(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Add Note'),
+ content: SizedBox(
+ width: 500,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: titleController,
+ autofocus: true,
+ decoration: InputDecoration(
+ labelText: 'Title',
+ labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)),
+ focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary)),
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: contentController,
+ decoration: InputDecoration(
+ labelText: 'Content',
+ labelStyle: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)),
+ focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary)),
+ ),
+ maxLines: 4,
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, false),
+ borderRadius: 12.0,
+ scaleFactor: 1.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.4)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
),
- const SizedBox(height: 8),
- TextField(
- controller: contentController,
- decoration: const InputDecoration(labelText: 'Content'),
- maxLines: 3,
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, true),
+ borderRadius: 12.0,
+ scaleFactor: 1.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.4),
+ border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
+ ),
+ child: Text('Add Note', style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)),
+ ),
),
],
),
- actions: [
- TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Add'),
- ),
- ],
- ),
- );
-
- if (result == true && widget.rommService != null) {
- final title = titleController.text.trim();
- final content = contentController.text.trim();
- if (title.isNotEmpty || content.isNotEmpty) {
- final success = await widget.rommService!.createRomNote(_currentGame.id, title, content);
- if (success) {
- _refreshGame();
- } else {
- if (mounted) {
- // ignore: use_build_context_synchronously
+ );
+
+ if (result == true && widget.rommService != null) {
+ final title = titleController.text.trim();
+ final content = contentController.text.trim();
+ if (title.isNotEmpty || content.isNotEmpty) {
+ final success = await widget.rommService!.createRomNote(_currentGame.id, title, content);
+ if (success) {
+ if (!mounted) return;
+ _refreshGame();
+ } else if (mounted) {
ErrorHandler.showException(context, Exception('Failed to create note'), contextLabel: 'Add Note');
}
}
}
+ } finally {
+ Future.delayed(const Duration(milliseconds: 300), () {
+ if (mounted) {
+ _isAddingNote = false;
+ }
+ });
}
}
Future<void> _deleteNote(int noteId) async {
if (widget.rommService == null) return;
-
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Note'),
content: const Text('Are you sure you want to delete this note?'),
actions: [
- TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Delete', style: TextStyle(color: Colors.red)),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, false),
+ borderRadius: 12.0,
+ scaleFactor: 1.0,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.4)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
+ ),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, true),
+ borderRadius: 12.0,
+ scaleFactor: 1.0,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Colors.red.withValues(alpha: 0.1),
+ border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
+ ),
+ child: const Text('Delete', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)),
+ ),
),
],
),
);
-
if (confirm == true) {
final success = await widget.rommService!.deleteRomNote(_currentGame.id, noteId);
if (success) {
+ if (!mounted) return;
_refreshGame();
- } else {
- if (mounted) {
- // ignore: use_build_context_synchronously
- ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Failed to delete note')));
- }
+ } else if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Failed to delete note')));
}
}
}
@@ -198,14 +379,47 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
- title: Text(note.title.isNotEmpty ? note.title : 'Note'),
- content: SingleChildScrollView(
- child: Text(note.content),
+ backgroundColor: Theme.of(context).colorScheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: Text(note.title.isNotEmpty ? note.title : 'Note', style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)),
+ content: SizedBox(
+ width: 500,
+ child: SingleChildScrollView(
+ child: Text(note.content, style: TextStyle(height: 1.5, color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
),
actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Close'),
+ FocusEffectWrapper(
+ onTap: () {
+ Navigator.pop(context);
+ _deleteNote(note.id);
+ },
+ borderRadius: 12.0,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Colors.red.withValues(alpha: 0.1),
+ border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
+ ),
+ child: const Text('Delete Note', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)),
+ ),
+ ),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context),
+ borderRadius: 12.0,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.4)),
+ ),
+ child: Text('Close', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
),
],
),
@@ -214,58 +428,28 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
Future<void> _saveProps(BuildContext context) async {
if (widget.rommService == null) return;
-
- final messenger = ScaffoldMessenger.of(context);
setState(() => _isSaving = true);
-
final prefs = ref.read(sharedPreferencesProvider);
final success = await widget.rommService!.updateRomProps(
- _currentGame.id,
- prefs,
- backlogged: _backlogged,
- nowPlaying: _nowPlaying,
- rating: _rating,
- status: _status,
- completion: _completion,
+ _currentGame.id, prefs, backlogged: _backlogged, nowPlaying: _nowPlaying,
+ rating: _rating, status: _status, completion: _completion,
);
-
if (mounted) {
setState(() => _isSaving = false);
if (success) {
_refreshGame();
- messenger.showSnackBar(
- const SnackBar(content: Text('Properties saved successfully')),
- );
- } else {
- // Safe to use ErrorHandler here if it's a global/static UI helper
- // but let's be double sure and check mounted again
- if (mounted) {
- // ignore: use_build_context_synchronously
- ErrorHandler.showException(context, Exception('Failed to update properties'), contextLabel: 'Update Status');
- }
+ ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Properties saved successfully')));
+ } else if (mounted) {
+ ErrorHandler.showException(context, Exception('Failed to update properties'), contextLabel: 'Update Status');
}
}
}
- void _showScreenshotFullscreen(BuildContext context, int initialIndex, List<String> imageUrls) {
- showDialog(
- context: context,
- useRootNavigator: true,
- builder: (context) => ScreenshotGalleryDialog(
- initialIndex: initialIndex,
- imageUrls: imageUrls,
- ),
- );
- }
-
String _normalizeUrl(String? path) {
if (path == null || path.isEmpty) return '';
if (path.startsWith('http')) return path;
- final base = widget.rommBaseUrl.endsWith('/')
- ? widget.rommBaseUrl.substring(0, widget.rommBaseUrl.length - 1)
- : widget.rommBaseUrl;
- final normalizedPath = path.startsWith('/') ? path : '/$path';
- return '$base$normalizedPath';
+ final base = widget.rommBaseUrl.endsWith('/') ? widget.rommBaseUrl.substring(0, widget.rommBaseUrl.length - 1) : widget.rommBaseUrl;
+ return '$base${path.startsWith('/') ? path : '/$path'}';
}
Future<bool> _showCancelConfirmation(BuildContext context, String gameName) async {
@@ -275,14 +459,8 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
title: const Text('Cancel Download'),
content: Text('Are you sure you want to cancel downloading $gameName? This will delete the partial file.'),
actions: [
- TextButton(
- onPressed: () => Navigator.pop(context, false),
- child: const Text('Keep'),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Cancel Download', style: TextStyle(color: Colors.red)),
- ),
+ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Keep')),
+ TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Cancel Download', style: TextStyle(color: Colors.red))),
],
),
);
@@ -294,703 +472,370 @@ class _GameDetailScreenState extends ConsumerState<GameDetailScreen> {
ref.listen(downloadProvider, (prev, next) {
final progress = next[_currentGame.id];
if (progress != null && progress.isComplete) {
- // Download just finished
_checkDownloadStatus();
- // Remove from progress map so we show the action buttons instead of "100% Done"
ref.read(downloadProvider.notifier).removeDownload(_currentGame.id);
}
});
final theme = Theme.of(context);
- final size = MediaQuery.of(context).size;
- final headerHeight = size.height * 0.4;
-
- String? backgroundUrl;
- if (_currentGame.screenshotUrl != null && _currentGame.screenshotUrl!.isNotEmpty) {
- backgroundUrl = _normalizeUrl(_currentGame.screenshotUrl);
- } else if (_currentGame.mergedScreenshots.isNotEmpty) {
- backgroundUrl = _normalizeUrl(_currentGame.mergedScreenshots.first);
- }
+ final headerHeight = MediaQuery.of(context).size.height * 0.4;
+ String? backgroundUrl = _currentGame.screenshotUrl != null && _currentGame.screenshotUrl!.isNotEmpty
+ ? _normalizeUrl(_currentGame.screenshotUrl)
+ : (_currentGame.mergedScreenshots.isNotEmpty ? _normalizeUrl(_currentGame.mergedScreenshots.first) : null);
return Scaffold(
- backgroundColor: Colors.black,
- body: SingleChildScrollView(
- child: Column(
+ backgroundColor: theme.scaffoldBackgroundColor,
+ body: Listener(
+ onPointerHover: (event) {
+ if (event.delta.distance > 0 && ref.read(inputModeProvider) != InputMode.mouse) {
+ ref.read(inputModeProvider.notifier).state = InputMode.mouse;
+ }
+ },
+ child: SingleChildScrollView(
+ child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // SECTION 1 - Hero header
- SizedBox(
- height: headerHeight,
- child: Stack(
- children: [
- // Background Image
- Positioned.fill(
- child: backgroundUrl != null
- ? CachedNetworkImage(
- imageUrl: backgroundUrl,
- fit: BoxFit.cover,
- placeholder: (context, url) => Container(color: Colors.grey[900]),
- errorWidget: (context, url, error) => Container(color: Colors.grey[900]),
- )
- : Container(color: Colors.grey[900]),
- ),
- // Gradient Overlay
- Positioned.fill(
- child: Container(
- decoration: const BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
- colors: [
- Colors.transparent,
- Colors.black87,
- Colors.black,
- ],
- stops: [0.5, 0.8, 1.0],
- ),
- ),
- ),
- ),
- // Back Button
- Positioned(
- top: MediaQuery.of(context).padding.top + 8,
- left: 16,
- child: CircleAvatar(
- backgroundColor: Colors.black54,
- child: IconButton(
- icon: const Icon(Icons.arrow_back, color: Colors.white),
- onPressed: () => Navigator.of(context).pop(),
- ),
- ),
- ),
- // Content (Cover + Title)
- Positioned(
- bottom: 16,
- left: 16,
- right: 16,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- // Cover Image
- Hero(
- tag: 'game_cover_${_currentGame.id}',
- child: Container(
- width: 130,
- height: 180,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.5),
- blurRadius: 10,
- offset: const Offset(0, 4),
- ),
- ],
- ),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: CachedNetworkImage(
- imageUrl: _normalizeUrl(_currentGame.pathCoverLarge),
- fit: BoxFit.cover,
- placeholder: (context, url) => Container(color: Colors.grey[800]),
- errorWidget: (context, url, error) => const Icon(Icons.image_not_supported),
- ),
- ),
- ),
- ),
- const SizedBox(width: 16),
- // Title and Platform
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- _currentGame.name,
- style: theme.textTheme.headlineSmall?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- ),
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- ),
- if (_currentGame.platformDisplayName != null)
- Text(
- _currentGame.platformDisplayName!,
- style: theme.textTheme.titleMedium?.copyWith(
- color: Colors.white70,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
-
+ _buildHeroHeader(context, headerHeight, backgroundUrl, theme),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // SECTION 2 - Action buttons row
- Consumer(
- builder: (context, ref, _) {
- final downloads = ref.watch(downloadProvider);
- final progress = downloads[_currentGame.id];
-
- return SizedBox(
- width: double.infinity,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- if (!_isDownloaded)
- if (progress != null) ...[
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8.0),
- child: DownloadProgressIndicator(
- progress: progress,
- compact: true,
- ),
- ),
- const SizedBox(height: 12),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- if (!progress.isComplete && progress.error == null)
- _ActionButton(
- icon: progress.isPaused ? Icons.play_arrow : Icons.pause,
- label: progress.isPaused ? 'Resume' : 'Pause',
- onPressed: () {
- if (progress.isPaused) {
- if (progress.game != null && progress.downloadUrl != null) {
- ref.read(downloadProvider.notifier).startDownload(
- progress.game!,
- progress.downloadUrl!,
- );
- }
- } else {
- ref.read(downloadProvider.notifier).pauseDownload(_currentGame.id);
- }
- },
- ),
- _ActionButton(
- icon: Icons.close,
- label: 'Cancel',
- color: Colors.red,
- onPressed: () async {
- if (progress.isComplete || progress.error != null) {
- ref.read(downloadProvider.notifier).cancelDownload(_currentGame.id);
- } else if (await _showCancelConfirmation(context, progress.gameName)) {
- ref.read(downloadProvider.notifier).cancelDownload(_currentGame.id);
- }
- },
- ),
- ],
- ),
- ] else
- _ActionButton(
- icon: Icons.download,
- label: 'Download',
- onPressed: () async {
- await widget.onDownload();
- _checkDownloadStatus();
- },
- )
- else ...[
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- Expanded(
- child: _ActionButton(
- icon: Icons.play_arrow,
- label: 'Play',
- onPressed: () async {
- if (_isDownloaded) {
- await widget.onLaunch();
- }
- },
- ),
- ),
- Expanded(
- child: _ActionButton(
- icon: Icons.cloud_upload,
- label: 'Push',
- onPressed: () async {
- if (_isDownloaded) {
- await widget.onPushSaves();
- }
- },
- ),
- ),
- Expanded(
- child: _ActionButton(
- icon: Icons.cloud_download,
- label: 'Pull',
- onPressed: () async {
- if (_isDownloaded) {
- await widget.onPullSaves();
- }
- },
- ),
- ),
- Expanded(
- child: _ActionButton(
- icon: Icons.folder_open,
- label: 'Open Folder',
- onPressed: () async {
- final ds = ref.read(directoryServiceProvider).value;
- if (ds != null) {
- final path = await ds.getRomDirectory(_currentGame);
- await SystemUtils.openDirectory(path);
- }
- },
- ),
- ),
- Expanded(
- child: _ActionButton(
- icon: Icons.delete,
- label: 'Delete',
- onPressed: () async {
- await widget.onDelete();
- // Invalidate download provider to clear "100% done" sticky state
- ref.invalidate(downloadProvider);
- _checkDownloadStatus();
- },
- color: Colors.red,
- ),
- ),
- ],
- ),
-
- const SizedBox(height: 24),
- const Divider(color: Colors.white10, height: 32),
- const Center(
- child: Text(
- 'LOCAL SAVE STATES',
- style: TextStyle(
- color: Colors.white54,
- fontSize: 10,
- fontWeight: FontWeight.bold,
- letterSpacing: 0.5,
- ),
- ),
- ),
- const SizedBox(height: 12),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- // ── Local Backup button ──────────────────
- _ActionButton(
- icon: Icons.save_alt_outlined,
- label: 'Backup',
- onPressed: () async {
- try {
- final syncService = await ref.read(saveSyncServiceProvider.future);
- final ds = await ref.read(directoryServiceProvider.future);
- if (!context.mounted) return;
- if (syncService == null || ds == null) {
- ErrorHandler.showInfo(context, 'Not Ready', message: 'Services not available');
- return;
- }
- final romPath = await ds.getRomFilePath(_currentGame);
- final backupService = ref.read(backupServiceProvider);
- final result = await backupService.createImmediate(_currentGame, romPath, syncService);
- if (!context.mounted) return;
- if (result != null) {
- final backupRepo = ref.read(backupRepositoryProvider);
- await backupRepo.addEntry(
- _currentGame.id,
- BackupEntry(
- timestamp: DateTime.now(),
- md5Hash: result.md5,
- localZipPath: result.zipPath,
- ),
- );
- if (context.mounted) {
- ErrorHandler.showSuccess(context, 'Backup Created', message: 'Local restore point saved.');
- }
-
- // Trigger the background sync queue immediately to push this new backup
- final rommService = ref.read(rommServiceProvider);
- if (rommService != null && !rommService.isOffline.value) {
- BackgroundSyncQueue.processQueue(rommService, backupRepo);
- }
- } else {
- ErrorHandler.showInfo(context, 'No Saves', message: 'No save files found to back up.');
- }
- } catch (e, st) {
- dev.log('Backup button failed', error: e, stackTrace: st);
- if (context.mounted) {
- ErrorHandler.showException(context, e, contextLabel: 'Local Backup');
- }
- }
- },
- ),
- const SizedBox(width: 48),
- // ── Restore button ───────────────────────
- _ActionButton(
- icon: Icons.history,
- label: 'Restore',
- onPressed: () async {
- try {
- final ds = ref.read(directoryServiceProvider).value;
- final romPath = ds != null
- ? await ds.getRomFilePath(_currentGame)
- : '';
- if (!context.mounted) return;
- await BackupHistorySheet.show(
- context,
- game: _currentGame,
- romPath: romPath,
- );
- } catch (e, st) {
- dev.log('Restore button failed', error: e, stackTrace: st);
- if (context.mounted) {
- ErrorHandler.showException(context, e, contextLabel: 'Local Restore');
- }
- }
- },
- ),
- ],
- ),
- ],
- ],
- ),
- );
- },
- ),
-
+ _buildActionsRow(),
const SizedBox(height: 24),
-
- // SECTION 3 - Metadata chips row
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- // Genres (first 2)
- ..._currentGame.genres.take(2).map((g) => _MetadataChip(label: g)),
- // Player Count
- if (_currentGame.playerCount != null && _currentGame.playerCount!.isNotEmpty)
- _MetadataChip(
- label: _currentGame.playerCount!,
- icon: Icons.people_outline,
- ),
- // Average Rating
- if (_currentGame.averageRating != null)
- _MetadataChip(
- label: '${_currentGame.averageRating!.toStringAsFixed(0)}/100',
- icon: Icons.star_outline,
- ),
- // Release Year
- if (_currentGame.firstReleaseDate != null)
- _MetadataChip(
- label: DateTime.fromMillisecondsSinceEpoch(_currentGame.firstReleaseDate!).year.toString(),
- icon: Icons.calendar_today_outlined,
- ),
- ],
- ),
+ _buildMetadataChips(),
const SizedBox(height: 24),
-
- // SECTION 4 - Description
- Text(
- 'About',
- style: theme.textTheme.titleLarge?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- ),
- ),
+ _buildSectionTitle(theme, 'About'),
const SizedBox(height: 8),
- Text(
- _currentGame.summary ?? 'No description available',
- style: theme.textTheme.bodyMedium?.copyWith(
- color: Colors.white70,
- height: 1.5,
- ),
- ),
+ Text(_currentGame.summary ?? 'No description available', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant, height: 1.5)),
const SizedBox(height: 24),
-
- // SECTION 5 - Details grid
- Text(
- 'Details',
- style: theme.textTheme.titleLarge?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- ),
- ),
+ _buildSectionTitle(theme, 'Details'),
const SizedBox(height: 12),
- _DetailsGrid(game: _currentGame),
+ GameDetailsGrid(game: _currentGame),
const SizedBox(height: 24),
-
- // SECTION 6 - Notes
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- 'Notes',
- style: theme.textTheme.titleLarge?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- ),
- ),
- IconButton(
- icon: const Icon(Icons.add_comment, color: Colors.blue),
- onPressed: _addNote,
- ),
- ],
- ),
- const SizedBox(height: 12),
- if (_currentGame.notes.isEmpty)
- const Text('No notes added yet.', style: TextStyle(color: Colors.white54, fontSize: 13))
- else
- ..._currentGame.notes.map((note) => Card(
- color: Colors.white10,
- margin: const EdgeInsets.only(bottom: 8),
- child: ListTile(
- onTap: () => _viewNote(note),
- title: Text(
- note.title.isNotEmpty ? note.title : 'Note',
- style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- subtitle: Text(
- note.content,
- style: const TextStyle(color: Colors.white70),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- trailing: IconButton(
- icon: const Icon(Icons.delete_outline, color: Colors.white38, size: 18),
- onPressed: () => _deleteNote(note.id),
- ),
- ),
- )),
+ GameNotesSection(notes: _currentGame.notes, onAddNote: _addNote, onViewNote: _viewNote),
const SizedBox(height: 24),
-
- // SECTION 7 - Screenshots
- if (_currentGame.mergedScreenshots.isNotEmpty) ...[
- Text(
- 'Screenshots',
- style: theme.textTheme.titleLarge?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 12),
- SizedBox(
- height: 120,
- child: ListView.separated(
- scrollDirection: Axis.horizontal,
- itemCount: _currentGame.mergedScreenshots.length,
- separatorBuilder: (context, index) => const SizedBox(width: 12),
- itemBuilder: (context, index) {
- final imageUrl = _normalizeUrl(_currentGame.mergedScreenshots[index]);
- return GestureDetector(
- onTap: () {
- final allUrls = _currentGame.mergedScreenshots
- .map((path) => _normalizeUrl(path))
- .toList();
- _showScreenshotFullscreen(context, index, allUrls);
- },
- child: ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: CachedNetworkImage(
- imageUrl: imageUrl,
- width: 200,
- height: 120,
- fit: BoxFit.cover,
- placeholder: (context, url) => Container(color: Colors.grey[900]),
- errorWidget: (context, url, error) => const Icon(Icons.image_not_supported),
- ),
- ),
- );
- },
- ),
- ),
- const SizedBox(height: 24),
- ],
-
- // SECTION 7 - Personal
- const SizedBox(height: 8),
- const Divider(color: Colors.white12),
- const SizedBox(height: 8),
- Text('Personal', style: theme.textTheme.titleLarge?.copyWith(
- color: Colors.white, fontWeight: FontWeight.bold)),
- const SizedBox(height: 16),
-
- // Status dropdown
- Row(
- children: [
- const Text('Status', style: TextStyle(color: Colors.white54, fontSize: 13)),
- const Spacer(),
- DropdownButton<String>(
- value: const [
- 'never_playing',
- 'incomplete',
- 'finished',
- 'completed_100',
- 'retired'
- ].contains(_status) ? _status : null,
- dropdownColor: Colors.grey[900],
- style: const TextStyle(color: Colors.white),
- hint: const Text('Not set', style: TextStyle(color: Colors.white54)),
- underline: const SizedBox(),
- items: const [
- DropdownMenuItem(value: 'never_playing', child: Text('Never Played')),
- DropdownMenuItem(value: 'incomplete', child: Text('Incomplete')),
- DropdownMenuItem(value: 'finished', child: Text('Finished')),
- DropdownMenuItem(value: 'completed_100', child: Text('100% Completed')),
- DropdownMenuItem(value: 'retired', child: Text('Dropped')),
- ],
- onChanged: (val) => setState(() => _status = val),
- ),
- ],
+ _buildScreenshotsSection(theme),
+ GamePersonalSection(
+ status: _status,
+ rating: _rating,
+ completion: _completion,
+ backlogged: _backlogged,
+ nowPlaying: _nowPlaying,
+ isSaving: _isSaving,
+ adjustingRating: _adjustingRating,
+ adjustingCompletion: _adjustingCompletion,
+ onStatusChanged: (val) => setState(() => _status = val),
+ onRatingChanged: (val) => setState(() => _rating = val),
+ onCompletionChanged: (val) => setState(() => _completion = val),
+ onBacklogChanged: (val) => setState(() => _backlogged = val),
+ onNowPlayingChanged: (val) => setState(() => _nowPlaying = val),
+ onToggleAdjustingRating: _toggleAdjustingRating,
+ onToggleAdjustingCompletion: _toggleAdjustingCompletion,
+ onSave: () => _saveProps(context),
),
+ const SizedBox(height: 80),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ bottomNavigationBar: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 300),
+ transitionBuilder: (child, animation) {
+ return SlideTransition(
+ position: Tween<Offset>(
+ begin: const Offset(0, 1),
+ end: Offset.zero,
+ ).animate(CurvedAnimation(
+ parent: animation,
+ curve: Curves.easeOutCubic,
+ )),
+ child: child,
+ );
+ },
+ child: ref.watch(inputModeProvider) != InputMode.mouse
+ ? ControllerHintsBar(
+ hints: [
+ ControllerHintItem(
+ label: _isDownloaded ? 'Play' : 'Download',
+ button: 'A'
+ ),
+ const ControllerHintItem(label: 'Back', button: 'B'),
+ ],
+ )
+ : const SizedBox.shrink(key: ValueKey('hide_detail_hints')),
+ ),
+ );
+ }
- // Rating stars
- Row(
+ Widget _buildHeroHeader(BuildContext context, double height, String? backgroundUrl, ThemeData theme) {
+ return SizedBox(
+ height: height,
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: backgroundUrl != null
+ ? CachedNetworkImage(imageUrl: backgroundUrl, fit: BoxFit.cover, placeholder: (_, __) => Container(color: Colors.grey[900]), errorWidget: (_, __, ___) => Container(color: Colors.grey[900]))
+ : Container(color: Colors.grey[900]),
+ ),
+ Positioned.fill(child: Container(decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black87, Colors.black], stops: [0.5, 0.8, 1.0])))),
+ Positioned(top: MediaQuery.of(context).padding.top + 8, left: 16, child: CircleAvatar(backgroundColor: Colors.black54, child: IconButton(icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.of(context).pop()))),
+ Positioned(
+ bottom: 16, left: 16, right: 16,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Hero(
+ tag: 'game_cover_${_currentGame.id}',
+ child: Container(
+ width: 130, height: 180,
+ decoration: BoxDecoration(borderRadius: BorderRadius.circular(8), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 10, offset: const Offset(0, 4))]),
+ child: ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: _normalizeUrl(_currentGame.pathCoverLarge), fit: BoxFit.cover, placeholder: (_, __) => Container(color: Colors.grey[800]), errorWidget: (_, __, ___) => const Icon(Icons.image_not_supported))),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
- const Text('Rating', style: TextStyle(color: Colors.white54, fontSize: 13)),
- const Spacer(),
- Row(
- children: List.generate(10, (i) => GestureDetector(
- onTap: () => setState(() => _rating = i + 1),
- child: Icon(
- i < _rating ? Icons.star : Icons.star_border,
- color: Colors.amber,
- size: 20,
- ),
- )),
- ),
+ Text(_currentGame.name, style: theme.textTheme.headlineSmall?.copyWith(color: Colors.white, fontWeight: FontWeight.bold), maxLines: 2, overflow: TextOverflow.ellipsis),
+ if (_currentGame.platformDisplayName != null) Text(_currentGame.platformDisplayName!, style: theme.textTheme.titleMedium?.copyWith(color: Colors.white60)),
],
),
- const SizedBox(height: 12),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
- // Completion slider
- Row(
- children: [
- const Text('Completion', style: TextStyle(color: Colors.white54, fontSize: 13)),
- const Spacer(),
- Text('$_completion%', style: const TextStyle(color: Colors.white)),
- ],
+ Widget _buildActionsRow() {
+ return Consumer(builder: (context, ref, _) {
+ final downloads = ref.watch(downloadProvider);
+ final progress = downloads[_currentGame.id];
+ if (!_isDownloaded) {
+ if (progress != null) {
+ return Column(children: [
+ Padding(padding: const EdgeInsets.symmetric(horizontal: 8.0), child: DownloadProgressIndicator(progress: progress, compact: true)),
+ const SizedBox(height: 16),
+ Row(mainAxisAlignment: MainAxisAlignment.center, children: [
+ if (!progress.isComplete && progress.error == null) ...[
+ GameActionButton(icon: progress.isPaused ? Icons.play_arrow : Icons.pause, label: progress.isPaused ? 'Resume' : 'Pause', onPressed: () {
+ if (progress.isPaused) { if (progress.game != null && progress.downloadUrl != null) ref.read(downloadProvider.notifier).startDownload(progress.game!, progress.downloadUrl!); }
+ else { ref.read(downloadProvider.notifier).pauseDownload(_currentGame.id); }
+ }),
+ const SizedBox(width: 16),
+ ],
+ GameActionButton(icon: Icons.close, label: 'Cancel', color: Colors.red, onPressed: () async {
+ if (progress.isComplete || progress.error != null) ref.read(downloadProvider.notifier).cancelDownload(_currentGame.id);
+ else if (await _showCancelConfirmation(context, progress.gameName)) ref.read(downloadProvider.notifier).cancelDownload(_currentGame.id);
+ }),
+ ]),
+ ]);
+ }
+ return Center(
+ child: SizedBox(
+ width: 384,
+ child: GameActionButton(
+ focusNode: _focusNode,
+ icon: Icons.download,
+ label: 'Download Game',
+ isPrimary: true,
+ onPressed: () async { await widget.onDownload(); _checkDownloadStatus(); }
+ ),
+ ),
+ );
+ }
+ return Center(
+ child: SizedBox(
+ width: 384,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ GameActionButton(
+ focusNode: _focusNode,
+ icon: Icons.play_arrow,
+ label: 'Play Game',
+ isPrimary: true,
+ onPressed: () async { if (_isDownloaded) await widget.onLaunch(); }
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: GameDetailActionButton(
+ icon: Icons.cloud_upload_outlined,
+ label: 'Push Saves',
+ onTap: () async { if (_isDownloaded) await widget.onPushSaves(); },
+ ),
),
- Slider(
- value: _completion.toDouble(),
- min: 0,
- max: 100,
- divisions: 20,
- label: '$_completion%',
- onChanged: (val) => setState(() => _completion = val.toInt()),
+ const SizedBox(width: 12),
+ Expanded(
+ child: GameDetailActionButton(
+ icon: Icons.cloud_download_outlined,
+ label: 'Pull Saves',
+ onTap: () async { if (_isDownloaded) await widget.onPullSaves(); },
+ ),
),
-
- // Toggles row
- Row(
- children: [
- Expanded(
- child: SwitchListTile(
- title: const Text('Backlog', style: TextStyle(fontSize: 13)),
- value: _backlogged,
- onChanged: (val) => setState(() => _backlogged = val),
- contentPadding: EdgeInsets.zero,
- ),
- ),
- Expanded(
- child: SwitchListTile(
- title: const Text('Now Playing', style: TextStyle(fontSize: 13)),
- value: _nowPlaying,
- onChanged: (val) => setState(() => _nowPlaying = val),
- contentPadding: EdgeInsets.zero,
- ),
- ),
- ],
+ ],
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: GameDetailActionButton(
+ icon: Icons.folder_open_outlined,
+ label: 'Folder',
+ onTap: () async {
+ final ds = ref.read(directoryServiceProvider).value;
+ if (ds != null) await SystemUtils.openDirectory(await ds.getRomDirectory(_currentGame));
+ },
+ ),
),
- const SizedBox(height: 16),
-
- // Save button
- SizedBox(
- width: double.infinity,
- child: ElevatedButton(
- onPressed: _isSaving ? null : () => _saveProps(context),
- child: _isSaving
- ? const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
- : const Text('Save'),
+ const SizedBox(width: 12),
+ Expanded(
+ child: GameDetailActionButton(
+ icon: Icons.backup_outlined,
+ label: 'Backups',
+ onTap: () => _showLocalBackupsMenu(ref),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: GameDetailActionButton(
+ icon: Icons.delete_outline,
+ label: 'Delete',
+ iconColor: Colors.redAccent,
+ textColor: Colors.redAccent,
+ onTap: () async { await widget.onDelete(); ref.invalidate(downloadProvider); _checkDownloadStatus(); },
),
),
- const SizedBox(height: 24),
],
),
- ),
- ],
+ ],
+ ),
),
- ),
- );
+ );
+ });
}
-}
-class _ActionButton extends StatelessWidget {
- final IconData icon;
- final String label;
- final VoidCallback onPressed;
- final Color? color;
+ Future<void> _handleLocalBackup(WidgetRef ref) async {
+ try {
+ final syncService = await ref.read(saveSyncServiceProvider.future);
+ if (!mounted) return;
+ final ds = await ref.read(directoryServiceProvider.future);
+ if (!mounted || syncService == null || ds == null) return;
+ final backupService = ref.read(backupServiceProvider);
+ final result = await backupService.createImmediate(_currentGame, await ds.getRomFilePath(_currentGame), syncService);
+ if (!mounted) return;
+ if (result != null) {
+ final backupRepo = ref.read(backupRepositoryProvider);
+ await backupRepo.addEntry(_currentGame.id, BackupEntry(timestamp: DateTime.now(), md5Hash: result.md5, localZipPath: result.zipPath));
+ if (!mounted) return;
+ ErrorHandler.showSuccess(context, 'Backup Created', message: 'Local restore point saved.');
+ final rommService = ref.read(rommServiceProvider);
+ if (rommService != null && !rommService.isOffline.value) BackgroundSyncQueue.processQueue(rommService, backupRepo);
+ } else {
+ ErrorHandler.showInfo(context, 'No Saves', message: 'No save files found to back up.');
+ }
+ } catch (e) { if (mounted) ErrorHandler.showException(context, e, contextLabel: 'Local Backup'); }
+ }
- const _ActionButton({
- required this.icon,
- required this.label,
- required this.onPressed,
- this.color,
- });
+ Future<void> _handleLocalRestore(WidgetRef ref) async {
+ try {
+ final ds = ref.read(directoryServiceProvider).value;
+ if (mounted) await BackupHistorySheet.show(context, game: _currentGame, romPath: ds != null ? await ds.getRomFilePath(_currentGame) : '');
+ } catch (e) { if (mounted) ErrorHandler.showException(context, e, contextLabel: 'Local Restore'); }
+ }
- @override
- Widget build(BuildContext context) {
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- IconButton.filledTonal(
- onPressed: onPressed,
- icon: Icon(icon, color: color),
- style: IconButton.styleFrom(
- padding: const EdgeInsets.all(12),
- ),
- ),
- const SizedBox(height: 4),
- Text(
- label,
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(color: color ?? Colors.white70),
- ),
- ],
- );
+ Widget _buildMetadataChips() {
+ return Wrap(spacing: 8, runSpacing: 8, children: [
+ ..._currentGame.genres.take(2).map((g) => GameMetadataChip(label: g)),
+ if (_currentGame.playerCount?.isNotEmpty ?? false) GameMetadataChip(label: _currentGame.playerCount!, icon: Icons.people_outline),
+ if (_currentGame.averageRating != null) GameMetadataChip(label: '${_currentGame.averageRating!.toStringAsFixed(0)}/100', icon: Icons.star_outline),
+ if (_currentGame.firstReleaseDate != null) GameMetadataChip(label: DateTime.fromMillisecondsSinceEpoch(_currentGame.firstReleaseDate!).year.toString(), icon: Icons.calendar_today_outlined),
+ ]);
}
-}
-class _MetadataChip extends StatelessWidget {
- final String label;
- final IconData? icon;
+ Widget _buildScreenshotsSection(ThemeData theme) {
+ if (_currentGame.mergedScreenshots.isEmpty) return const SizedBox.shrink();
+ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ _buildSectionTitle(theme, 'Screenshots'),
+ const SizedBox(height: 12),
+ SizedBox(
+ height: 120,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal, itemCount: _currentGame.mergedScreenshots.length, separatorBuilder: (_, __) => const SizedBox(width: 12),
+ itemBuilder: (ctx, index) {
+ final url = _normalizeUrl(_currentGame.mergedScreenshots[index]);
+ return GestureDetector(
+ onTap: () => showDialog(context: context, useRootNavigator: true, builder: (_) => ScreenshotGalleryDialog(initialIndex: index, imageUrls: _currentGame.mergedScreenshots.map(_normalizeUrl).toList())),
+ child: ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: url, width: 200, height: 120, fit: BoxFit.cover, placeholder: (_, __) => Container(color: Colors.grey[900]), errorWidget: (_, __, ___) => const Icon(Icons.image_not_supported))),
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: 24),
+ ]);
+ }
- const _MetadataChip({required this.label, this.icon});
+ Widget _buildSectionTitle(ThemeData theme, String title) => Text(title, style: theme.textTheme.titleLarge?.copyWith(color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold));
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
- decoration: BoxDecoration(
- color: Colors.white.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(16),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (icon != null) ...[
- Icon(icon, size: 14, color: Colors.white70),
- const SizedBox(width: 6),
+ Future<void> _showLocalBackupsMenu(WidgetRef ref) async {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: Theme.of(context).colorScheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: Row(
+ children: [
+ Icon(Icons.backup_outlined, color: Theme.of(context).colorScheme.primary),
+ const SizedBox(width: 12),
+ Text('Local Saves', style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold)),
],
- Text(
- label,
- style: const TextStyle(color: Colors.white, fontSize: 12),
+ ),
+ content: Text(
+ 'Choose an action to perform on your local save backups.',
+ style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ actions: [
+ FocusEffectWrapper(
+ onTap: () {
+ Navigator.pop(ctx);
+ _handleLocalBackup(ref);
+ },
+ borderRadius: 12.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.4),
+ border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
+ ),
+ child: Text('Create Backup', style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)),
+ ),
+ ),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () {
+ Navigator.pop(ctx);
+ _handleLocalRestore(ref);
+ },
+ borderRadius: 12.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.4)),
+ ),
+ child: Text('Restore Backup', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
),
],
),
@@ -998,48 +843,66 @@ class _MetadataChip extends StatelessWidget {
}
}
-class _DetailsGrid extends StatelessWidget {
- final Game game;
+class GameDetailActionButton extends StatelessWidget {
+ final IconData icon;
+ final String label;
+ final VoidCallback onTap;
+ final Color? textColor;
+ final Color? iconColor;
- const _DetailsGrid({required this.game});
+ const GameDetailActionButton({
+ super.key,
+ required this.icon,
+ required this.label,
+ required this.onTap,
+ this.textColor,
+ this.iconColor,
+ });
@override
Widget build(BuildContext context) {
- final details = <String, String>{};
- if (game.companies.isNotEmpty) details['Developer'] = game.companies.join(', ');
- if (game.regions.isNotEmpty) details['Regions'] = game.regions.join(', ');
- if (game.languages.isNotEmpty) details['Languages'] = game.languages.join(', ');
- if (game.playerCount != null && game.playerCount!.isNotEmpty) details['Players'] = game.playerCount!;
-
- if (details.isEmpty) return const SizedBox.shrink();
-
- return GridView.builder(
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: 2,
- mainAxisExtent: 40,
- crossAxisSpacing: 16,
- mainAxisSpacing: 8,
- ),
- itemCount: details.length,
- itemBuilder: (context, index) {
- final entry = details.entries.elementAt(index);
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ final theme = Theme.of(context);
+ final isDestructive = textColor == Colors.red || textColor == Colors.redAccent;
+ return FocusEffectWrapper(
+ onTap: onTap,
+ borderRadius: 14.0,
+ scaleFactor: 1.05,
+ child: Container(
+ height: 48,
+ decoration: BoxDecoration(
+ color: isDestructive
+ ? Colors.red.withValues(alpha: 0.05)
+ : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: isDestructive
+ ? Colors.red.withValues(alpha: 0.15)
+ : theme.colorScheme.outline.withValues(alpha: 0.3),
+ width: 1.0,
+ ),
+ ),
+ alignment: Alignment.center,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
children: [
- Text(
- entry.key,
- style: const TextStyle(color: Colors.white54, fontSize: 12),
- ),
- Text(
- entry.value,
- style: const TextStyle(color: Colors.white, fontSize: 14),
- overflow: TextOverflow.ellipsis,
+ Icon(icon, size: 16, color: iconColor ?? (isDestructive ? Colors.redAccent : theme.colorScheme.onSurfaceVariant)),
+ const SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: textColor ?? theme.colorScheme.onSurface.withValues(alpha: 0.85),
+ letterSpacing: 0.2,
+ ),
+ ),
),
],
- );
- },
+ ),
+ ),
);
}
}
diff --git a/lib/ui/screens/library_actions.dart b/lib/ui/screens/library_actions.dart
index cb6fe31..c0b823d 100644
--- a/lib/ui/screens/library_actions.dart
+++ b/lib/ui/screens/library_actions.dart
@@ -24,11 +24,11 @@ import '../../core/emulator/strategies/windows_strategy.dart';
import '../../core/emulator/strategies/retroarch_strategy.dart';
import '../widgets/windows_game_config_dialog.dart';
import '../widgets/multi_disc_picker.dart';
-import '../widgets/save_conflict_dialog.dart';
import '../../core/save/save_sync_service.dart';
+import './library_dialog_service.dart';
+import '../widgets/focus_effect_wrapper.dart';
mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
- // These need to be implemented by the state class
Map<String, bool> get downloadedStates;
void refreshDownloadState(DirectoryService dirService, Game game);
void refreshAllDownloadStates();
@@ -48,17 +48,14 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
final dirService = ref.read(directoryServiceProvider).asData?.value;
if (dirService != null) {
Future.delayed(const Duration(seconds: 2), () {
- // Refresh the new background cache
ref.read(downloadedGamesCacheProvider.notifier).refresh();
});
}
}
Future<void> handleLaunch(BuildContext context, WidgetRef ref, Game game) async {
- // Ensure strategy registry preferences are loaded
final registryReady = await ref.read(strategyRegistryProvider.future);
- if (!context.mounted) return;
- if (registryReady == null) return;
+ if (!context.mounted || registryReady == null) return;
final strategy = registryReady.getStrategyForSlug(game.platformSlug ?? '');
if (strategy == null) {
@@ -66,13 +63,11 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
return;
}
- // Wait for save sync service to be ready (FutureProvider chain)
final syncService = await ref.read(saveSyncServiceProvider.future);
if (!context.mounted) return;
final dir = await ref.read(directoryServiceProvider.future);
- if (!context.mounted) return;
- if (dir == null) {
+ if (!context.mounted || dir == null) {
ErrorHandler.showInfo(context, 'Not Available', message: 'Storage service not available');
return;
}
@@ -83,86 +78,30 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (existingRomPath == null) {
if (!context.mounted) return;
- final shouldDownload = await showDialog<bool>(
- context: context,
- builder: (ctx) => AlertDialog(
- title: const Text('ROM not found'),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('${game.name} is not downloaded yet.'),
- const SizedBox(height: 8),
- const Text('Expected location:',
- style: TextStyle(fontWeight: FontWeight.bold)),
- const SizedBox(height: 4),
- SelectableText(
- expectedRomPath,
- style: const TextStyle(fontSize: 12, color: Colors.grey),
- ),
- const SizedBox(height: 4),
- const Text(
- 'You can also manually place the ROM file there.',
- style: TextStyle(fontSize: 11, color: Colors.grey),
- ),
- const SizedBox(height: 12),
- const Text('Would you like to download it now?'),
- ],
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(false),
- child: const Text('Cancel'),
- ),
- ElevatedButton(
- onPressed: () => Navigator.of(ctx).pop(true),
- child: const Text('Download'),
- ),
- ],
- ),
- );
- if (!context.mounted) return;
- if (shouldDownload == true) {
- startDownload(context, ref, game);
- }
+ final shouldDownload = await _showMissingRomDialog(context, game.name, expectedRomPath);
+ if (context.mounted && shouldDownload == true) startDownload(context, ref, game);
return;
}
String romPath = existingRomPath;
bool isAutoDetected = await io.File(existingRomPath).exists();
- // Multi-disc picker logic
if (game.hasMultipleFiles && game.files.isNotEmpty) {
- // If we already auto-detected a specific file (not a folder) that exists, skip the picker
if (isAutoDetected && !await io.Directory(romPath).exists()) {
debugPrint('[LibraryActions] Automatic detection found file: $romPath. Skipping picker.');
} else {
if (!context.mounted) return;
String? selectedFilePath;
- await MultiDiscPicker.show(
- context,
- game: game,
- files: game.files,
- onSelect: (file) {
- selectedFilePath = file['full_path']?.toString() ?? file['file_name']?.toString();
- },
- );
- if (selectedFilePath == null) return; // user dismissed
+ await MultiDiscPicker.show(context, game: game, files: game.files, onSelect: (file) {
+ selectedFilePath = file['full_path']?.toString() ?? file['file_name']?.toString();
+ });
+ if (selectedFilePath == null) return;
- debugPrint('[LibraryActions] Raw selectedFilePath from RomM: $selectedFilePath');
-
- // SANITIZE: Multi-disc file paths from RomM might contain characters like ':'
- // We must sanitize them segment by segment to preserve path separators (folders)
- // while matching how DirectoryService stores them on disk (spaces instead of invalid chars, collapsed spaces)
final segments = selectedFilePath!.split(RegExp(r'[/\\]'));
- final sanitizedSegments = segments.map((s) =>
- s.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim()
- ).toList();
-
+ final sanitizedSegments = segments.map((s) => s.replaceAll(RegExp(r'[<>:"/\\|?*]'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim()).toList();
romPath = sanitizedSegments.join(io.Platform.isWindows ? '\\' : '/');
if (!context.mounted) return;
- // Prefix with ROMs root if not absolute
if (!romPath.startsWith('/') && !romPath.contains(':\\')) {
final romsRoot = await dir.getRomsDirectory();
romPath = p.join(romsRoot, romPath);
@@ -170,14 +109,8 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
}
}
- // FINAL VALIDATION: Ensure the path actually exists on disk before handing to emulator
if (!await io.File(romPath).exists() && !await io.Directory(romPath).exists()) {
- debugPrint('[LibraryActions] ERROR: romPath does not exist on disk: $romPath');
- // One last try: if it has 'roms/' in path, try without it? Or vice-versa?
- // For now, show error to help debug
- if (context.mounted) {
- ErrorHandler.showInfo(context, 'File Not Found', message: 'The ROM was not found at the expected location. Please try downloading it again.');
- }
+ if (context.mounted) ErrorHandler.showInfo(context, 'File Not Found', message: 'The ROM was not found at the expected location.');
return;
}
@@ -187,124 +120,31 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
ErrorHandler.showInfo(context, 'Syncing Saves', message: 'Pushing saves for ${game.name}...');
try {
await syncService.pushSaves(game, romPath, syncMode: syncMode);
- } on SaveMappingRequiredException {
- if (!context.mounted) return;
- final strategy = syncService.getStrategyForSlug(game.platformSlug);
- if (strategy is EdenSaveStrategy || strategy is RyujinxSaveStrategy || strategy is AzaharSaveStrategy) {
- final selectedFolder = await showFolderMappingDialog(context, strategy);
- if (selectedFolder != null) {
- await syncService.saveMappedFolder(game.id, selectedFolder);
- if (context.mounted) {
- await syncService.pushSaves(game, romPath, syncMode: syncMode);
- }
- }
- }
- } on ProfileConflictException catch (e) {
- if (!context.mounted) return;
- final selectedProfile = await showProfileConflictDialog(context, e.profiles);
- if (selectedProfile != null) {
- await syncService.saveActiveProfile(selectedProfile);
- if (context.mounted) {
- await syncService.pushSaves(game, romPath, syncMode: syncMode);
- }
- }
- } on SaveConflictException catch (e) {
- if (!context.mounted) return;
- final choice = await showDialog<String>(
- context: context,
- builder: (ctx) => SaveConflictDialog(conflict: e),
- );
- if (choice == 'local' && context.mounted) {
- await syncService.pushSaves(game, romPath, syncMode: syncMode, force: true);
- } else if (choice == 'cloud' && context.mounted) {
- await syncService.pullSave(game, romPath);
- }
} catch (e) {
- // Ignore other push errors during launch to not block playing
+ if (context.mounted) await _handleSyncError(context, e, game, romPath, syncService, syncMode, push: true);
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).clearSnackBars();
try {
final pulled = await syncService.pullSave(game, romPath);
- if (!context.mounted) return;
- if (pulled) {
- ErrorHandler.showSuccess(context, 'Save Synced', message: 'Cloud save restored');
- }
- } on SaveMappingRequiredException {
- if (!context.mounted) return;
- final strategy = syncService.getStrategyForSlug(game.platformSlug);
- if (strategy is EdenSaveStrategy || strategy is RyujinxSaveStrategy || strategy is AzaharSaveStrategy) {
- final selectedFolder = await showFolderMappingDialog(context, strategy);
- if (selectedFolder != null) {
- await syncService.saveMappedFolder(game.id, selectedFolder);
- if (context.mounted) {
- await syncService.pullSave(game, romPath);
- }
- }
- }
- } on ProfileConflictException catch (e) {
- if (!context.mounted) return;
- final selectedProfile = await showProfileConflictDialog(context, e.profiles);
- if (selectedProfile != null) {
- await syncService.saveActiveProfile(selectedProfile);
- if (context.mounted) {
- await syncService.pullSave(game, romPath);
- }
- }
+ if (context.mounted && pulled) ErrorHandler.showSuccess(context, 'Save Synced', message: 'Cloud save restored');
} catch (e) {
- if (!context.mounted) return;
- final shouldContinue = await showDialog<bool>(
- context: context,
- builder: (ctx) => AlertDialog(
- title: const Text('Save Sync Warning'),
- content: Text(
- '${e.toString().replaceAll('Exception: ', '')}\n\nYou can still play, but your cloud save will not be restored. After playing once, exit the game and sync saves manually.'),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(false),
- child: const Text('Cancel'),
- ),
- ElevatedButton(
- onPressed: () => Navigator.of(ctx).pop(true),
- child: const Text('Play Anyway'),
- ),
- ],
- ),
- );
- if (!context.mounted) return;
- if (shouldContinue != true) return;
+ if (context.mounted) {
+ final playAnyway = await _handleSyncError(context, e, game, romPath, syncService, 'both', push: false);
+ if (playAnyway != true) return;
+ }
}
}
- // Check for 3DS keys before starting
- final is3ds = [
- '3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds',
- 'new-nintendo-3ds', 'new-nintendo-3ds-xl'
- ].contains(game.platformSlug?.toLowerCase());
-
- if (is3ds) {
+ // Platform-specific checks (e.g. 3DS keys)
+ if (['3ds', 'n3ds', 'nintendo-3ds', 'nintendo3ds', 'new-nintendo-3ds', 'new-nintendo-3ds-xl'].contains(game.platformSlug?.toLowerCase())) {
final systemDir = await dir.getEmulatorSystemDirectory(strategy.emulatorId);
- final keysSubPath = strategy.emulatorId == 'retroarch' ? 'citra/sysdata/aes_keys.txt' : 'sysdata/aes_keys.txt';
- final keysPath = '$systemDir/$keysSubPath';
-
+ final keysPath = '$systemDir/${strategy.emulatorId == 'retroarch' ? 'citra/sysdata/aes_keys.txt' : 'sysdata/aes_keys.txt'}';
if (!await File(keysPath).exists()) {
final prefs = ref.read(sharedPreferencesProvider);
- final shownOnce = prefs.getBool('shown_3ds_keys_warning') ?? false;
- if (!shownOnce && context.mounted) {
- await showDialog(
- context: context,
- builder: (ctx) => AlertDialog(
- title: const Text('Missing 3DS Keys'),
- content: const Text('Note: Decrypted 3DS ROMs require aes_keys.txt in your emulator system folder to run correctly.'),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(),
- child: const Text('OK'),
- ),
- ],
- ),
- );
+ if (!(prefs.getBool('shown_3ds_keys_warning') ?? false) && context.mounted) {
+ await showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text('Missing 3DS Keys'), content: const Text('Note: Decrypted 3DS ROMs require aes_keys.txt in your emulator system folder.'), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK'))]));
await prefs.setBool('shown_3ds_keys_warning', true);
}
}
@@ -314,223 +154,127 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (!context.mounted) return;
ErrorHandler.showInfo(context, 'Launching', message: 'Launching ${game.name}...');
- // --- Safety Sandwich: pre-launch restore-point ---
BackupResult? preBackup;
if (syncService != null) {
try {
final backupService = ref.read(backupServiceProvider);
- final backupRepo = ref.read(backupRepositoryProvider);
preBackup = await backupService.createImmediate(game, romPath, syncService);
- if (preBackup != null) {
- await backupRepo.addEntry(
- game.id,
- BackupEntry(
- timestamp: DateTime.now(),
- md5Hash: preBackup.md5,
- localZipPath: preBackup.zipPath,
- ),
- );
- debugPrint('[SafetySandwich] Pre-launch restore point created: ${preBackup.zipPath}');
- }
- } catch (e, st) {
- dev.log('Safety Sandwich pre-launch backup failed', error: e, stackTrace: st);
- }
+ if (preBackup != null) await ref.read(backupRepositoryProvider).addEntry(game.id, BackupEntry(timestamp: DateTime.now(), md5Hash: preBackup.md5, localZipPath: preBackup.zipPath));
+ } catch (e) { dev.log('Pre-launch backup failed', error: e); }
}
Process? process = await strategy.launchWithHandle(game, romPath);
if (!context.mounted) return;
- if (process == null) {
- // Fall back to regular launch if no process handle available
- await strategy.launch(game, romPath);
- } else {
- // Start background Future to handle process exit
+ if (process == null) await strategy.launch(game, romPath);
+ else {
unawaited(Future.delayed(Duration.zero, () async {
try {
await process.exitCode;
if (!context.mounted) return;
-
ErrorHandler.showInfo(context, 'Syncing', message: 'Auto-syncing saves...');
-
- bool ok = false;
if (syncService != null) {
final syncMode = ref.read(retroarchSyncModeProvider);
- ok = await syncService.pushSaves(game, romPath, syncMode: syncMode);
-
- // --- Safety Sandwich: post-exit backup if save changed ---
+ final ok = await syncService.pushSaves(game, romPath, syncMode: syncMode);
try {
final backupService = ref.read(backupServiceProvider);
- final backupRepo = ref.read(backupRepositoryProvider);
final postBackup = await backupService.createImmediate(game, romPath, syncService);
if (postBackup != null && postBackup.md5 != preBackup?.md5) {
- await backupRepo.addEntry(
- game.id,
- BackupEntry(
- timestamp: DateTime.now(),
- md5Hash: postBackup.md5,
- localZipPath: postBackup.zipPath,
- ),
- );
- debugPrint('[SafetySandwich] Post-exit backup saved: ${postBackup.zipPath}');
+ await ref.read(backupRepositoryProvider).addEntry(game.id, BackupEntry(timestamp: DateTime.now(), md5Hash: postBackup.md5, localZipPath: postBackup.zipPath));
} else if (postBackup != null) {
- // No change — discard the redundant zip
final f = io.File(postBackup.zipPath);
if (await f.exists()) await f.delete();
}
- } catch (e, st) {
- dev.log('Safety Sandwich post-exit backup failed', error: e, stackTrace: st);
+ } catch (e) { dev.log('Post-exit backup failed', error: e); }
+ if (context.mounted) {
+ if (ok) ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves synced');
+ else ErrorHandler.showSuccess(context, 'Up to Date', message: 'No files to upload');
}
}
-
- if (!context.mounted) return;
- if (ok) {
- ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves synced');
- } else if (syncService != null) {
- ErrorHandler.showSuccess(context, 'Up to Date', message: 'No files to upload');
- }
- } catch (e) {
- // Silently ignore errors in auto-sync / backup
- }
+ } catch (_) {}
}));
}
} 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()),
- );
+ final shouldInstall = await _showMissingCoreDialog(context, e.coreName);
+ if (shouldInstall == 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();
- ErrorHandler.showException(context, err, contextLabel: 'Download Core Failed');
- }
- }
+ await (strategy as RetroArchStrategy).downloadCore(e.coreName, File(e.corePath).parent.path, Dio());
+ if (context.mounted) { Navigator.pop(context); await handleLaunch(context, ref, game); }
+ } catch (err) { if (context.mounted) { Navigator.pop(context); ErrorHandler.showException(context, err, contextLabel: 'Download Core Failed'); } }
}
- return;
- }
-
- final isWindows =
- ['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '');
- final isMissingExe =
- e.toString().contains('No executable') || e.toString().contains('not found');
- if (isWindows && isMissingExe) {
+ } else if ((['windows', 'pc', 'win'].contains(game.platformSlug?.toLowerCase() ?? '')) && (e.toString().contains('No executable') || e.toString().contains('not found'))) {
await handleWindowsConfig(context, ref, game);
- } else {
- ErrorHandler.showException(context, e, contextLabel: 'Launch Failed');
- }
+ } else { ErrorHandler.showException(context, e, contextLabel: 'Launch Failed'); }
}
}
- Future<void> handleWindowsConfig(
- BuildContext context, WidgetRef ref, Game game) async {
+ Future<void> handleWindowsConfig(BuildContext context, WidgetRef ref, Game game) async {
final registry = ref.read(strategyRegistryProvider).asData?.value;
- final windowsStrategy =
- registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?;
+ final windowsStrategy = registry?.getStrategyForSlug(game.platformSlug ?? '') as WindowsStrategy?;
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),
- currentSavePath:
- syncService?.windowsSaveStrategy.getManualOverride(game.id),
- ),
- );
- 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);
+ final result = await showDialog<Map<String, String>>(context: context, builder: (ctx) => WindowsGameConfigDialog(game: game, currentExePath: windowsStrategy?.getExeOverride(game.id), currentSavePath: syncService?.windowsSaveStrategy.getManualOverride(game.id)));
+ if (result == null) return;
+ if (result['exe']?.isNotEmpty ?? false) await windowsStrategy?.setExeOverride(game.id, result['exe']!);
+ if (result['save']?.isNotEmpty ?? false) await syncService?.windowsSaveStrategy.setManualOverride(game.id, result['save']!);
+ if (context.mounted) await handleLaunch(context, ref, game);
}
Future<void> handleDeleteRom(BuildContext context, WidgetRef ref, Game game) async {
final dirService = ref.read(directoryServiceProvider).asData?.value;
if (dirService == null) return;
-
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete ROM?'),
- content: Text('Are you sure you want to delete the local files for ${game.name}? This cannot be undone.'),
+ content: Text('Are you sure you want to delete the local files for ${game.name}?'),
actions: [
- TextButton(
- onPressed: () => Navigator.of(ctx).pop(false),
- child: const Text('Cancel'),
+ FocusEffectWrapper(
+ useSafeScale: false,
+ onTap: () => Navigator.pop(ctx, false),
+ borderRadius: 12.0,
+ autofocus: true,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(ctx).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(ctx).colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(ctx).colorScheme.onSurfaceVariant)),
+ ),
),
- ElevatedButton(
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.red,
- foregroundColor: Colors.white,
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ useSafeScale: false,
+ onTap: () => Navigator.pop(ctx, true),
+ borderRadius: 12.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Colors.red.withValues(alpha: 0.1),
+ border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
+ ),
+ child: const Text('Delete', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)),
),
- onPressed: () => Navigator.of(ctx).pop(true),
- child: const Text('Delete'),
),
],
),
);
-
if (confirmed != true) return;
-
try {
await dirService.deleteRom(game);
- if (!context.mounted) return;
-
- // Refresh the new background cache
ref.read(downloadedGamesCacheProvider.notifier).refresh();
-
- if (!context.mounted) return;
- ErrorHandler.showSuccess(context, 'ROM Deleted', message: 'Local files for ${game.name} were removed.');
- } catch (e) {
- if (!context.mounted) return;
- ErrorHandler.showException(context, e, contextLabel: 'Delete Failed');
- }
+ if (context.mounted) ErrorHandler.showSuccess(context, 'ROM Deleted', message: 'Local files removed.');
+ } catch (e) { if (context.mounted) ErrorHandler.showException(context, e, contextLabel: 'Delete Failed'); }
}
- Future<void> handlePushSaves(
- BuildContext context, WidgetRef ref, Game game) async {
+ Future<void> handlePushSaves(BuildContext context, WidgetRef ref, Game game) async {
final syncService = await ref.read(saveSyncServiceProvider.future);
- if (!context.mounted) return;
- if (syncService == null) {
- ErrorHandler.showInfo(context, 'Sync Unavailable', message: 'Save sync not available');
- return;
- }
-
+ if (!context.mounted || syncService == null) { ErrorHandler.showInfo(context, 'Sync Unavailable', message: 'Save sync not available'); return; }
final dir = ref.read(directoryServiceProvider).asData?.value;
String romPath = '';
if (dir != null) {
@@ -539,283 +283,208 @@ mixin LibraryActionsMixin<T extends ConsumerStatefulWidget> on ConsumerState<T>
if (!context.mounted) return;
final syncMode = ref.read(retroarchSyncModeProvider);
-
try {
- if (!context.mounted) return;
ErrorHandler.showInfo(context, 'Syncing', message: 'Uploading saves for ${game.name}...');
final ok = await syncService.pushSaves(game, romPath, syncMode: syncMode, force: true);
- if (!context.mounted) return;
- if (ok) {
- ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves uploaded');
- } else {
- ErrorHandler.showSuccess(context, 'Up to Date', message: 'No files to upload');
- }
- } on SaveMappingRequiredException {
- if (!context.mounted) return;
- final strategy = syncService.getStrategyForSlug(game.platformSlug);
- final selectedFolder = await showFolderMappingDialog(context, strategy);
- if (selectedFolder != null) {
- await syncService.saveMappedFolder(game.id, selectedFolder);
- if (!context.mounted) return;
- // Retry
- return handlePushSaves(context, ref, game);
+ if (context.mounted) {
+ if (ok) ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves uploaded');
+ else ErrorHandler.showSuccess(context, 'Up to Date', message: 'No files to upload');
}
- } on ProfileConflictException catch (e) {
- if (!context.mounted) return;
- final selectedProfile = await showProfileConflictDialog(context, e.profiles);
- if (selectedProfile != null) {
- await syncService.saveActiveProfile(selectedProfile);
- if (!context.mounted) return;
- return handlePushSaves(context, ref, game);
- }
- } on SaveConflictException catch (e) {
- if (!context.mounted) return;
- final choice = await showDialog<String>(
- context: context,
- builder: (ctx) => SaveConflictDialog(conflict: e),
- );
- if (choice == 'local' && context.mounted) {
- await syncService.pushSaves(game, romPath, syncMode: syncMode, force: true);
- if (context.mounted) ErrorHandler.showSuccess(context, 'Sync Resolved', message: 'Local save uploaded');
- } else if (choice == 'cloud' && context.mounted) {
- await syncService.pullSave(game, romPath);
- if (context.mounted) ErrorHandler.showSuccess(context, 'Sync Resolved', message: 'Cloud save restored');
- }
- } catch (e) {
- if (!context.mounted) return;
- ErrorHandler.showException(context, e, contextLabel: 'Push Saves Error');
- }
+ } catch (e) { if (context.mounted) await _handleSyncError(context, e, game, romPath, syncService, syncMode, push: true); }
}
- Future<void> handlePullSaves(
- BuildContext context, WidgetRef ref, Game game) async {
+ Future<void> handlePullSaves(BuildContext context, WidgetRef ref, Game game) async {
final syncService = await ref.read(saveSyncServiceProvider.future);
- if (!context.mounted) return;
- if (syncService == null) {
- ErrorHandler.showInfo(context, 'Sync Unavailable', message: 'Save sync not available');
- return;
- }
-
+ if (!context.mounted || syncService == null) { ErrorHandler.showInfo(context, 'Sync Unavailable', message: 'Save sync not available'); return; }
final dir = ref.read(directoryServiceProvider).asData?.value;
final romPath = dir != null ? await dir.getRomFilePath(game) : '';
if (!context.mounted) return;
-
try {
- if (!context.mounted) return;
- ErrorHandler.showInfo(context, 'Syncing', message: 'Fetching cloud saves for ${game.name}...');
+ ErrorHandler.showInfo(context, 'Syncing', message: 'Fetching cloud saves...');
final saves = await syncService.getSavesForGame(game.id);
if (!context.mounted) return;
-
- if (saves.isEmpty) {
- if (!context.mounted) return;
- ErrorHandler.showInfo(context, 'No Saves', message: 'No cloud saves found for this game.');
- return;
- }
-
- if (!context.mounted) return;
- final selectedSave = await showSaveSelectionDialog(context, saves);
- if (selectedSave == null) return;
-
- if (!context.mounted) return;
+ if (saves.isEmpty) { ErrorHandler.showInfo(context, 'No Saves', message: 'No cloud saves found.'); return; }
+ final selectedSave = await LibraryDialogService.showSaveSelectionDialog(context, saves);
+ if (selectedSave == null || !context.mounted) return;
ErrorHandler.showInfo(context, 'Syncing', message: 'Downloading selected save...');
final ok = await syncService.pullSave(game, romPath, saveData: selectedSave);
-
- if (!context.mounted) return;
- if (ok) {
- ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves downloaded');
- } else {
- if (!context.mounted) return;
- ErrorHandler.showInfo(context, 'Retry Sync', message: 'Save appears unchanged. Retrying with force...');
- // Force pull by clearing the pull timestamp
- final prefs = ref.read(sharedPreferencesProvider);
- await prefs.remove('last_pull_${game.id}');
- if (context.mounted) {
- final retryOk = await syncService.pullSave(game, romPath, saveData: selectedSave);
+ if (context.mounted) {
+ if (ok) ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves downloaded');
+ else {
+ ErrorHandler.showInfo(context, 'Retry Sync', message: 'Save unchanged. Retrying with force...');
+ await ref.read(sharedPreferencesProvider).remove('last_pull_${game.id}');
if (context.mounted) {
- if (retryOk) {
- ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves downloaded');
- } else {
- // Should not reach here if pullSave throws, but for safety:
- ErrorHandler.showInfo(context, 'Sync Incomplete', message: 'Save file was downloaded but the emulator strategy could not apply it.');
+ final retryOk = await syncService.pullSave(game, romPath, saveData: selectedSave);
+ if (context.mounted) {
+ if (retryOk) ErrorHandler.showSuccess(context, 'Save Synced', message: 'Saves downloaded');
+ else ErrorHandler.showInfo(context, 'Sync Incomplete', message: 'Save applied but strategy failed.');
}
}
}
}
- } on SaveMappingRequiredException {
- if (!context.mounted) return;
+ } catch (e) { if (context.mounted) await _handleSyncError(context, e, game, romPath, syncService, 'both', push: false); }
+ }
+
+ Future<dynamic> _handleSyncError(BuildContext context, dynamic e, Game game, String romPath, SaveSyncService syncService, String syncMode, {required bool push}) async {
+ if (e is SaveMappingRequiredException) {
final strategy = syncService.getStrategyForSlug(game.platformSlug);
- final selectedFolder = await showFolderMappingDialog(context, strategy);
+ final selectedFolder = await LibraryDialogService.showFolderMappingDialog(context, strategy);
if (selectedFolder != null) {
await syncService.saveMappedFolder(game.id, selectedFolder);
- if (!context.mounted) return;
- // Retry
- return handlePullSaves(context, ref, game);
+ if (context.mounted) return push ? handlePushSaves(context, ref, game) : handlePullSaves(context, ref, game);
}
- } on ProfileConflictException catch (e) {
- if (!context.mounted) return;
- final selectedProfile = await showProfileConflictDialog(context, e.profiles);
+ } else if (e is ProfileConflictException) {
+ final selectedProfile = await LibraryDialogService.showProfileConflictDialog(context, e.profiles);
if (selectedProfile != null) {
await syncService.saveActiveProfile(selectedProfile);
- if (!context.mounted) return;
- return handlePullSaves(context, ref, game);
+ if (context.mounted) return push ? handlePushSaves(context, ref, game) : handlePullSaves(context, ref, game);
}
- } catch (e) {
- if (!context.mounted) return;
- ErrorHandler.showException(context, e, contextLabel: 'Pull Saves Error');
- }
- }
-
- Future<Map<String, dynamic>?> showSaveSelectionDialog(BuildContext context, List<Map<String, dynamic>> saves) async {
- return showDialog<Map<String, dynamic>>(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Select Cloud Save'),
- content: SizedBox(
- width: double.maxFinite,
- child: ListView.builder(
- shrinkWrap: true,
- itemCount: saves.length,
- itemBuilder: (context, index) {
- final save = saves[index];
- final fileName = save['file_name_no_ext'] ?? save['file_name'] ?? 'Unknown Save';
- final createdAtStr = save['created_at'] ?? save['updated_at'] ?? '';
- final createdAt = DateTime.tryParse(createdAtStr.toString());
-
- String subtitle = 'Unknown date';
- if (createdAt != null) {
- final diff = DateTime.now().difference(createdAt);
- if (diff.inDays > 0) {
- subtitle = '${diff.inDays}d ago';
- } else if (diff.inHours > 0) {
- subtitle = '${diff.inHours}h ago';
- } else if (diff.inMinutes > 0) {
- subtitle = '${diff.inMinutes}m ago';
- } else {
- subtitle = 'just now';
- }
- }
-
- return ListTile(
- title: Text(fileName.toString()),
- subtitle: Text(subtitle),
- onTap: () => Navigator.pop(context, save),
- );
- },
- ),
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Cancel'),
- ),
- ],
- ),
- );
+ } else if (e is SaveConflictException) {
+ final choice = await LibraryDialogService.showSaveConflictDialog(context, e);
+ if (choice == 'local' && context.mounted) {
+ await syncService.pushSaves(game, romPath, syncMode: syncMode, force: true);
+ if (context.mounted) ErrorHandler.showSuccess(context, 'Sync Resolved', message: 'Local save uploaded');
+ } else if (choice == 'cloud' && context.mounted) {
+ await syncService.pullSave(game, romPath);
+ if (context.mounted) ErrorHandler.showSuccess(context, 'Sync Resolved', message: 'Cloud save restored');
+ }
+ } else if (!push) {
+ return await showDialog<bool>(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Save Sync Warning'),
+ content: Text('${e.toString().replaceAll('Exception: ', '')}\n\nPlay anyway?'),
+ actions: [
+ FocusEffectWrapper(
+ useSafeScale: false,
+ onTap: () => Navigator.pop(ctx, false),
+ borderRadius: 12.0,
+ autofocus: true,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(ctx).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(ctx).colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(ctx).colorScheme.onSurfaceVariant)),
+ ),
+ ),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(ctx, true),
+ borderRadius: 12.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Colors.indigo.withValues(alpha: 0.1),
+ border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
+ ),
+ child: const Text('Play Anyway', style: TextStyle(color: Colors.indigoAccent, fontWeight: FontWeight.bold)),
+ ),
+ ),
+ ],
+ ),
+ );
+ } else { ErrorHandler.showException(context, e, contextLabel: push ? 'Push Saves Error' : 'Pull Saves Error'); }
+ return null;
}
-
- Future<String?> showProfileConflictDialog(BuildContext context, List<Map<String, dynamic>> profiles) async {
- return showDialog<String>(
+ Future<bool?> _showMissingRomDialog(BuildContext context, String gameName, String expectedPath) {
+ return showDialog<bool>(
context: context,
- builder: (context) => AlertDialog(
- title: const Text('Multiple Profiles Detected'),
- content: SizedBox(
- width: double.maxFinite,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Text('Multiple save profiles have recent activity. Which one would you like to use?'),
- const SizedBox(height: 16),
- Flexible(
- child: ListView.builder(
- shrinkWrap: true,
- itemCount: profiles.length,
- itemBuilder: (context, index) {
- final profile = profiles[index];
- final id = profile['id'] as String;
- final lastActive = profile['newestFile'] as DateTime;
-
- final diff = DateTime.now().difference(lastActive);
- String timeAgo;
- if (diff.inDays > 0) {
- timeAgo = '${diff.inDays}d ago';
- } else if (diff.inHours > 0) {
- timeAgo = '${diff.inHours}h ago';
- } else if (diff.inMinutes > 0) {
- timeAgo = '${diff.inMinutes}m ago';
- } else {
- timeAgo = 'just now';
- }
-
- return ListTile(
- title: Text(id),
- subtitle: Text('Last active: $timeAgo'),
- onTap: () => Navigator.pop(context, id),
- );
- },
+ builder: (ctx) => Dialog(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text('ROM not found', style: Theme.of(ctx).textTheme.titleLarge),
+ const SizedBox(height: 12),
+ Text('$gameName is not downloaded yet.'),
+ const SizedBox(height: 8),
+ const Text('Expected location:', style: TextStyle(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 4),
+ SelectableText(expectedPath, style: const TextStyle(fontSize: 12, color: Colors.grey)),
+ const SizedBox(height: 12),
+ const Text('Download now?'),
+ const SizedBox(height: 24),
+ Row(mainAxisAlignment: MainAxisAlignment.end, children: [
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(ctx, false),
+ borderRadius: 12.0, autofocus: true, useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(ctx).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(ctx).colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(ctx).colorScheme.onSurfaceVariant)),
),
),
- ],
- ),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(ctx, true),
+ borderRadius: 12.0, useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Colors.indigo.withValues(alpha: 0.1),
+ border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
+ ),
+ child: const Text('Download', style: TextStyle(color: Colors.indigoAccent, fontWeight: FontWeight.bold)),
+ ),
+ ),
+ ]),
+ ]),
),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Cancel'),
- ),
- ],
),
);
}
- Future<String?> showFolderMappingDialog(BuildContext context, dynamic strategy) async {
- final folders = await strategy.getAvailableSaveFolders();
- if (!context.mounted) return null;
-
- return showDialog<String>(
+ Future<bool?> _showMissingCoreDialog(BuildContext context, String coreName) {
+ return showDialog<bool>(
context: context,
- builder: (context) => AlertDialog(
- title: const Text('Select Save Folder'),
- content: SizedBox(
- width: double.maxFinite,
- child: folders.isEmpty
- ? const Text('No saves found. Launch the game in the emulator first.')
- : ListView.builder(
- shrinkWrap: true,
- itemCount: folders.length,
- itemBuilder: (context, index) {
- final folder = folders[index];
- final name = folder['name'] as String;
- final path = (folder['path'] ?? folder['name']) as String;
- final date = folder['lastModified'] as DateTime;
-
- // Simple relative time string
- final diff = DateTime.now().difference(date);
- String timeAgo;
- if (diff.inDays > 0) {
- timeAgo = '${diff.inDays}d ago';
- } else if (diff.inHours > 0) {
- timeAgo = '${diff.inHours}h ago';
- } else if (diff.inMinutes > 0) {
- timeAgo = '${diff.inMinutes}m ago';
- } else {
- timeAgo = 'just now';
- }
-
- return ListTile(
- title: Text(name),
- subtitle: Text('Last played: $timeAgo'),
- onTap: () => Navigator.pop(context, path),
- );
- },
+ builder: (ctx) => Dialog(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text('RetroArch Core Missing', style: Theme.of(ctx).textTheme.titleLarge),
+ const SizedBox(height: 12),
+ Text('The core $coreName is required. Download and install it automatically?'),
+ const SizedBox(height: 24),
+ Row(mainAxisAlignment: MainAxisAlignment.end, children: [
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(ctx, false),
+ borderRadius: 12.0, autofocus: true, useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(ctx).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(ctx).colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(ctx).colorScheme.onSurfaceVariant)),
),
+ ),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(ctx, true),
+ borderRadius: 12.0, useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Colors.indigo.withValues(alpha: 0.1),
+ border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
+ ),
+ child: const Text('Install', style: TextStyle(color: Colors.indigoAccent, fontWeight: FontWeight.bold)),
+ ),
+ ),
+ ]),
+ ]),
),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Cancel'),
- ),
- ],
),
);
}
diff --git a/lib/ui/screens/library_dialog_service.dart b/lib/ui/screens/library_dialog_service.dart
new file mode 100644
index 0000000..55942f7
--- /dev/null
+++ b/lib/ui/screens/library_dialog_service.dart
@@ -0,0 +1,264 @@
+import 'package:flutter/material.dart';
+import '../../core/save/save_sync_service.dart';
+import '../widgets/save_conflict_dialog.dart';
+import '../widgets/focus_effect_wrapper.dart';
+
+class LibraryDialogService {
+ static Future<Map<String, dynamic>?> showSaveSelectionDialog(BuildContext context, List<Map<String, dynamic>> saves) async {
+ return showDialog<Map<String, dynamic>>(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Select Cloud Save'),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: ListView.builder(
+ shrinkWrap: true,
+ itemCount: saves.length,
+ itemBuilder: (context, index) {
+ final save = saves[index];
+ final fileName = save['file_name_no_ext'] ?? save['file_name'] ?? 'Unknown Save';
+ final createdAtStr = save['created_at'] ?? save['updated_at'] ?? '';
+ final createdAt = DateTime.tryParse(createdAtStr.toString());
+
+ String subtitle = 'Unknown date';
+ if (createdAt != null) {
+ final diff = DateTime.now().difference(createdAt);
+ if (diff.inDays > 0) subtitle = '${diff.inDays}d ago';
+ else if (diff.inHours > 0) subtitle = '${diff.inHours}h ago';
+ else if (diff.inMinutes > 0) subtitle = '${diff.inMinutes}m ago';
+ else subtitle = 'just now';
+ }
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4.0),
+ child: FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, save),
+ borderRadius: 12.0,
+ scaleFactor: 1.0, useSafeScale: false,
+ autofocus: index == 0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.2),
+ border: Border.all(color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3)),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.cloud_download, color: Theme.of(context).colorScheme.primary),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(fileName.toString(), style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
+ const SizedBox(height: 4),
+ Text(subtitle, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ actions: [
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context),
+ borderRadius: 12.0,
+ scaleFactor: 1.0, useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ static Future<String?> showProfileConflictDialog(BuildContext context, List<Map<String, dynamic>> profiles) async {
+ return showDialog<String>(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Multiple Profiles Detected'),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text('Multiple save profiles have recent activity. Which one would you like to use?'),
+ const SizedBox(height: 16),
+ Flexible(
+ child: ListView.builder(
+ shrinkWrap: true,
+ itemCount: profiles.length,
+ itemBuilder: (context, index) {
+ final profile = profiles[index];
+ final id = profile['id'] as String;
+ final lastActive = profile['newestFile'] as DateTime;
+
+ final diff = DateTime.now().difference(lastActive);
+ String timeAgo;
+ if (diff.inDays > 0) timeAgo = '${diff.inDays}d ago';
+ else if (diff.inHours > 0) timeAgo = '${diff.inHours}h ago';
+ else if (diff.inMinutes > 0) timeAgo = '${diff.inMinutes}m ago';
+ else timeAgo = 'just now';
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4.0),
+ child: FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, id),
+ borderRadius: 12.0,
+ scaleFactor: 1.0, useSafeScale: false,
+ autofocus: index == 0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.2),
+ border: Border.all(color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3)),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.account_circle, color: Theme.of(context).colorScheme.primary),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(id, style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
+ const SizedBox(height: 4),
+ Text('Last active: $timeAgo', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context),
+ borderRadius: 12.0,
+ scaleFactor: 1.0, useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ static Future<String?> showFolderMappingDialog(BuildContext context, dynamic strategy) async {
+ final folders = await strategy.getAvailableSaveFolders();
+ if (!context.mounted) return null;
+
+ return showDialog<String>(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Select Save Folder'),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: folders.isEmpty
+ ? const Text('No saves found. Launch the game in the emulator first.')
+ : ListView.builder(
+ shrinkWrap: true,
+ itemCount: folders.length,
+ itemBuilder: (context, index) {
+ final folder = folders[index];
+ final name = folder['name'] as String;
+ final date = folder['lastModified'] as DateTime;
+
+ final diff = DateTime.now().difference(date);
+ String timeAgo;
+ if (diff.inDays > 0) timeAgo = '${diff.inDays}d ago';
+ else if (diff.inHours > 0) timeAgo = '${diff.inHours}h ago';
+ else if (diff.inMinutes > 0) timeAgo = '${diff.inMinutes}m ago';
+ else timeAgo = 'just now';
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4.0),
+ child: FocusEffectWrapper(
+ onTap: () => Navigator.pop(context, (folder['path'] ?? folder['name']) as String),
+ borderRadius: 12.0,
+ scaleFactor: 1.0, useSafeScale: false,
+ autofocus: index == 0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.2),
+ border: Border.all(color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3)),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.folder, color: Theme.of(context).colorScheme.primary),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(name, style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface)),
+ const SizedBox(height: 4),
+ Text('Last active: $timeAgo', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.7))),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ actions: [
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(context),
+ borderRadius: 12.0,
+ scaleFactor: 1.0, useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
+ border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ static Future<String?> showSaveConflictDialog(BuildContext context, SaveConflictException e) async {
+ return showDialog<String>(
+ context: context,
+ builder: (ctx) => SaveConflictDialog(conflict: e),
+ );
+ }
+}
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index 7c931a0..e4407b8 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -9,6 +10,8 @@ import '../../providers/paginated_games_provider.dart';
import '../../providers/downloaded_games_cache_provider.dart';
import '../../core/storage/directory_service.dart';
import '../../core/romm/romm_models.dart';
+import '../../core/romm/romm_service.dart';
+import '../../core/constants/gaming_quotes.dart';
import '../widgets/game_card.dart';
import '../widgets/platform_filter_bar.dart';
import '../widgets/filter_bottom_sheet.dart';
@@ -16,9 +19,13 @@ import 'library_skeleton.dart';
import 'game_detail_screen.dart';
import 'library_actions.dart';
+import '../../core/input/input_action_bus.dart';
+import '../../core/input/gamepad_service.dart';
import '../../providers/download_provider.dart';
import '../../providers/ui_provider.dart';
+import '../widgets/focus_effect_wrapper.dart';
+import '../widgets/controller_hints_bar.dart';
final isHomeSelectedProvider = StateProvider<bool>((ref) => true);
@@ -31,27 +38,91 @@ class LibraryScreen extends ConsumerStatefulWidget {
class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActionsMixin {
late TextEditingController _searchController;
- final FocusNode _focusNode = FocusNode();
+ final FocusNode _searchFocusNode = FocusNode();
+ final FocusNode _platformTabsFocusNode = FocusNode();
+ final FocusNode _firstPlatformChipFocusNode = FocusNode();
+ final FocusNode _topActionsFocusNode = FocusNode();
+ final FocusNode _firstContentItemFocusNode = FocusNode();
+
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
GlobalKey<RefreshIndicatorState>();
late ScrollController _scrollController;
+ StreamSubscription<GameAction>? _inputSub;
+ bool _isFilterSheetOpen = false;
+ late final int _sessionSeed;
@override
void initState() {
super.initState();
+ _sessionSeed = DateTime.now().millisecondsSinceEpoch;
_searchController = TextEditingController(text: ref.read(searchQueryProvider));
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
+
+ _searchFocusNode.onKeyEvent = (node, event) {
+ if (event is! KeyUpEvent) {
+ if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
+ _firstPlatformChipFocusNode.requestFocus();
+ return KeyEventResult.handled;
+ }
+ if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
+ _topActionsFocusNode.requestFocus();
+ return KeyEventResult.handled;
+ }
+ }
+ return KeyEventResult.ignored;
+ };
+
+ _inputSub = inputActionBus.stream.listen((action) {
+ if (!mounted) return;
+ switch (action) {
+ case GameAction.detail:
+ if (_isFilterSheetOpen) Navigator.pop(context);
+ else _openFilterSheet(context, ref);
+ break;
+ case GameAction.favorite:
+ _scrollToTopAndFocusSearch();
+ break;
+ case GameAction.back:
+ if (_isFilterSheetOpen) {
+ Navigator.pop(context);
+ }
+ break;
+ default:
+ break;
+ }
+ });
+
WidgetsBinding.instance.addPostFrameCallback((_) {
- _focusNode.requestFocus();
+ if (mounted) _scrollToTopAndFocusSearch();
});
}
+ void _scrollToTopAndFocusSearch() {
+ if (_scrollController.hasClients && _scrollController.offset > 0) {
+ _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic);
+ }
+ _searchFocusNode.requestFocus();
+ }
+
+ Future<void> _handleRandomGame() async {
+ final service = ref.read(rommServiceProvider);
+ if (service == null) return;
+ final game = await service.getRandomGame();
+ if (game == null) return;
+ if (mounted) _handleGameTap(context, ref, game);
+ }
+
@override
void dispose() {
_searchController.dispose();
- _focusNode.dispose();
+ _searchFocusNode.dispose();
+ _platformTabsFocusNode.dispose();
+ _firstPlatformChipFocusNode.dispose();
+ _topActionsFocusNode.dispose();
+ _firstContentItemFocusNode.dispose();
_scrollController.dispose();
+ _inputSub?.cancel();
super.dispose();
}
@@ -78,7 +149,6 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
final config = ref.read(rommConfigProvider).value;
final baseUrl = config?.baseUrl ?? '';
final downloads = ref.read(downloadProvider);
- // CRITICAL: Active download always blocks Playable state
final isActuallyDownloading = downloads.containsKey(game.id);
final isDownloaded = (ref.read(downloadedGamesCacheProvider)[game.id] ?? false) && !isActuallyDownloading;
@@ -98,23 +168,19 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
),
),
);
-
- if (mounted) {
- await ref.read(downloadedGamesCacheProvider.notifier).refresh();
- }
+ if (mounted) await ref.read(downloadedGamesCacheProvider.notifier).refresh();
}
Future<void> _openFilterSheet(BuildContext context, WidgetRef ref) async {
final currentFilters = ref.read(activeFiltersProvider);
final collections = await ref.read(rommServiceProvider)?.getCollections() ?? [];
-
final games = ref.read(paginatedGamesProvider).games;
final genres = games.expand((g) => g.genres).toSet().toList()..sort();
final regions = games.expand((g) => g.regions).toSet().toList()..sort();
final languages = games.expand((g) => g.languages).toSet().toList()..sort();
if (!context.mounted) return;
-
+ setState(() => _isFilterSheetOpen = true);
await FilterBottomSheet.show(
context,
currentFilters: currentFilters,
@@ -132,20 +198,15 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
);
},
);
+ if (mounted) setState(() => _isFilterSheetOpen = false);
}
@override
Map<String, bool> get downloadedStates => ref.watch(downloadedGamesCacheProvider);
-
@override
- void refreshDownloadState(DirectoryService dirService, Game game) {
- ref.read(downloadedGamesCacheProvider.notifier).refresh();
- }
-
+ void refreshDownloadState(DirectoryService dirService, Game game) => ref.read(downloadedGamesCacheProvider.notifier).refresh();
@override
- void refreshAllDownloadStates() {
- ref.read(downloadedGamesCacheProvider.notifier).refresh();
- }
+ void refreshAllDownloadStates() => ref.read(downloadedGamesCacheProvider.notifier).refresh();
Widget _buildHorizontalShelf({
required BuildContext context,
@@ -153,73 +214,56 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
required IconData icon,
required List<Game> games,
required WidgetRef ref,
+ bool isFirst = false,
}) {
if (games.isEmpty) return const SizedBox.shrink();
-
final cardAspectRatio = ref.watch(cardAspectRatioProvider);
final showTitle = ref.watch(showTitleProvider);
final columnCount = ref.watch(columnCountProvider);
final cardSpacing = ref.watch(cardSpacingProvider);
-
- // "home has a -1 in row" logic: Use base columnCount for home (which is one less than grid)
final screenWidth = MediaQuery.of(context).size.width;
const padding = 24.0;
final totalSpacing = cardSpacing * (columnCount - 1);
final cardWidth = (screenWidth - padding - totalSpacing) / columnCount;
-
final coverHeight = cardWidth / (cardAspectRatio <= 0 ? 0.75 : cardAspectRatio);
final footerHeight = showTitle ? 60.0 : 32.0;
- final shelfHeight = coverHeight + footerHeight + 10.0; // Extra buffer
+ final shelfHeight = coverHeight + footerHeight + 10.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
- child: Row(
- children: [
- Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
- const SizedBox(width: 8),
- Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
- ],
- ),
+ child: Row(children: [Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), const SizedBox(width: 8), Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18))]),
),
SizedBox(
height: shelfHeight,
child: ScrollConfiguration(
- behavior: ScrollConfiguration.of(context).copyWith(
- dragDevices: {
- PointerDeviceKind.touch,
- PointerDeviceKind.mouse,
- PointerDeviceKind.trackpad,
- },
- ),
+ behavior: ScrollConfiguration.of(context).copyWith(dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad}),
child: ListView.builder(
scrollDirection: Axis.horizontal,
+ clipBehavior: Clip.none,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: games.length,
itemBuilder: (context, index) {
final game = games[index];
final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
-
- // Watch states for GameCard
final downloadedCache = ref.watch(downloadedGamesCacheProvider);
final downloads = ref.watch(downloadProvider);
final isActuallyDownloading = downloads.containsKey(game.id);
final isDownloaded = (downloadedCache[game.id] ?? false) && !isActuallyDownloading;
- return GestureDetector(
- onTap: () => _handleGameTap(context, ref, game),
- child: Container(
- width: cardWidth,
- margin: const EdgeInsets.only(right: 12),
+ return Container(
+ width: cardWidth,
+ margin: const EdgeInsets.only(right: 12),
+ child: FocusEffectWrapper(
+ focusNode: (isFirst && index == 0) ? _firstContentItemFocusNode : null,
+ onTap: () => _handleGameTap(context, ref, game),
child: GameCard(
game: game,
coverUrl: coverUrl,
isDownloaded: isDownloaded,
- platformLogoUrl: game.platformSlug != null
- ? '${ref.read(rommConfigProvider).value?.baseUrl ?? ''}/assets/platforms/${game.platformSlug}.svg'
- : null,
+ platformLogoUrl: game.platformSlug != null ? '${ref.read(rommConfigProvider).value?.baseUrl ?? ''}/assets/platforms/${game.platformSlug}.svg' : null,
showTitle: showTitle,
),
),
@@ -239,412 +283,472 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
final paginatedState = ref.watch(paginatedGamesProvider);
final cardAspectRatio = ref.watch(cardAspectRatioProvider);
final columnCount = ref.watch(columnCountProvider);
-
- // "home has a -1 in row" logic:
- // The main grid now uses the base columnCount (the normal count).
- // The Home shelf logic inside _buildHorizontalShelf remains the same,
- // where it calculates cardWidth based on the same columnCount,
- // effectively keeping them at the preferred "Home" size while the
- // grid remains at the "Normal" size.
final gridColumnCount = columnCount;
-
final cardSpacing = ref.watch(cardSpacingProvider);
final showTitle = ref.watch(showTitleProvider);
final directoryServiceAsync = ref.watch(directoryServiceProvider);
final downloadedCache = ref.watch(downloadedGamesCacheProvider);
final isSyncing = ref.watch(downloadedGamesCacheProvider.notifier).isSyncing;
final isHomeSelected = ref.watch(isHomeSelectedProvider);
-
- // Trigger initial load once service becomes available
- ref.listen(rommServiceProvider, (prev, next) {
- if (prev == null && next != null) {
- ref.read(paginatedGamesProvider.notifier).loadInitial(platformId: null);
- }
- });
-
- // Reload when platform changes
- ref.listen<int?>(selectedPlatformIdProvider, (prev, next) {
- if (prev != next) {
- ref.read(paginatedGamesProvider.notifier).loadInitial(
- platformId: next?.toString(),
- );
- }
- });
-
final rommService = ref.watch(rommServiceProvider);
+ final inputMode = ref.watch(inputModeProvider);
return Scaffold(
appBar: AppBar(
title: Row(
children: [
- Image.asset(
- 'freegosy_logo.png',
- height: 32,
- width: 32,
- ),
+ Image.asset('freegosy_logo.png', height: 32, width: 32),
const SizedBox(width: 12),
Expanded(
- child: rommService == null
- ? Consumer(
- builder: (context, ref, _) {
- final config = ref.watch(rommConfigProvider).value;
- return Text(
- config?.baseUrl ?? 'Loading...',
- style: const TextStyle(color: Colors.grey),
- overflow: TextOverflow.ellipsis,
- );
- },
- )
- : ValueListenableBuilder<bool>(
- valueListenable: rommService.isOffline,
- builder: (context, offline, _) {
- return Text(
- offline ? "${rommService.config.baseUrl} - Offline Mode" : rommService.config.baseUrl,
- style: offline ? const TextStyle(color: Colors.orange) : null,
- overflow: TextOverflow.ellipsis,
- );
- },
- ),
+ child: _buildAppBarTitle(context, ref, rommService),
),
],
),
actions: [
- Consumer(
- builder: (context, ref, _) {
- final hasFilters = ref.watch(activeFiltersProvider).hasActiveFilters;
- final isScanning = ref.watch(isScanningProvider);
-
- return Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (isScanning)
- const Padding(
- padding: EdgeInsets.only(right: 4.0),
- child: SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
+ Focus(
+ focusNode: _topActionsFocusNode,
+ skipTraversal: true,
+ onKeyEvent: (node, event) {
+ if (event is! KeyUpEvent && event.logicalKey == LogicalKeyboardKey.arrowDown) {
+ _scrollToTopAndFocusSearch();
+ return KeyEventResult.handled;
+ }
+ return KeyEventResult.ignored;
+ },
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Consumer(
+ builder: (context, ref, _) {
+ final hasFilters = ref.watch(activeFiltersProvider).hasActiveFilters;
+ final isScanning = ref.watch(isScanningProvider);
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (isScanning) const Padding(padding: EdgeInsets.only(right: 4.0), child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))),
+ FocusEffectWrapper(
+ borderRadius: 24, scaleFactor: 1.1,
+ onTap: () => _openFilterSheet(context, ref),
+ child: Stack(
+ children: [
+ IconButton(icon: const Icon(Icons.tune), tooltip: 'Filter', onPressed: () => _openFilterSheet(context, ref)),
+ if (hasFilters) Positioned(right: 8, top: 8, child: Container(width: 8, height: 8, decoration: const BoxDecoration(color: Colors.deepPurple, shape: BoxShape.circle))),
+ ],
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ FocusEffectWrapper(borderRadius: 24, scaleFactor: 1.1, onTap: _handleRandomGame, child: IconButton(icon: const Icon(Icons.shuffle), tooltip: 'Random game', onPressed: _handleRandomGame)),
+ const SizedBox(width: 8),
+ ],
+ ),
+ ),
+ ],
+ ),
+ body: Listener(
+ onPointerHover: (event) {
+ if (event.delta.distance > 0 && ref.read(inputModeProvider) != InputMode.mouse) ref.read(inputModeProvider.notifier).state = InputMode.mouse;
+ },
+ child: Column(
+ children: [
+ if (isSyncing) const LinearProgressIndicator(minHeight: 2, backgroundColor: Colors.transparent),
+ Expanded(
+ child: RefreshIndicator(
+ key: _refreshIndicatorKey,
+ onRefresh: _refreshLibrary,
+ child: CustomScrollView(
+ controller: _scrollController,
+ physics: const AlwaysScrollableScrollPhysics(),
+ slivers: [
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
+ sliver: SliverToBoxAdapter(
+ child: TextField(
+ focusNode: _searchFocusNode,
+ controller: _searchController,
+ decoration: InputDecoration(
+ hintText: 'Search games...',
+ prefixIcon: const Icon(Icons.search),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)),
+ // Exclusive Focus Visuals:
+ // Only show the focused border color if we are NOT in mouse mode
+ enabledBorder: (inputMode == InputMode.mouse)
+ ? null
+ : OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ borderSide: BorderSide(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)),
+ ),
+ ),
+ onChanged: (value) {
+ ref.read(searchQueryProvider.notifier).state = value;
+ if (value.isNotEmpty) {
+ // Force visual tab transition to "All" platform tab
+ ref.read(isHomeSelectedProvider.notifier).state = false;
+ ref.read(selectedPlatformIdProvider.notifier).state = null;
+
+ // Load initial games globally matching the search term
+ ref.read(paginatedGamesProvider.notifier).reset();
+ ref.read(paginatedGamesProvider.notifier).loadInitial(platformId: null, search: value);
+ } else {
+ // Reset visual tab transition back to "Home" tab
+ ref.read(isHomeSelectedProvider.notifier).state = true;
+ ref.read(selectedPlatformIdProvider.notifier).state = null;
+
+ // Clear search and reload Home page content
+ ref.read(paginatedGamesProvider.notifier).reset();
+ ref.read(paginatedGamesProvider.notifier).loadInitial(platformId: null, search: null);
+ }
+ },
),
),
),
- Stack(
- children: [
- IconButton(
- icon: const Icon(Icons.tune),
- tooltip: 'Filter',
- onPressed: () => _openFilterSheet(context, ref),
+ SliverToBoxAdapter(
+ child: platformsAsync.when(
+ data: (platforms) => PlatformFilterBar(
+ platforms: platforms,
+ selectedPlatformId: selectedPlatformId,
+ isHome: isHomeSelected,
+ focusNode: _platformTabsFocusNode,
+ firstChipFocusNode: _firstPlatformChipFocusNode,
+ onNavigateUp: () => _scrollToTopAndFocusSearch(),
+ onNavigateDown: () {
+ // Lock navigation so the GamepadService's geometric _moveFocus
+ // does not fire after us and jump to a random grid item.
+ ref.read(navigationLockedProvider.notifier).state = true;
+ if (_scrollController.hasClients) {
+ _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
+ }
+ _firstContentItemFocusNode.requestFocus();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) ref.read(navigationLockedProvider.notifier).state = false;
+ });
+ },
+ onSelected: (platform) {
+ debugPrint('📂 Platform selected: ${platform?.name} (ID: ${platform?.id})');
+ ref.read(isHomeSelectedProvider.notifier).state = false;
+ ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
+
+ // Load initial games page for selected platform
+ ref.read(paginatedGamesProvider.notifier).reset();
+ ref.read(paginatedGamesProvider.notifier).loadInitial(
+ platformId: platform?.id.toString(),
+ search: ref.read(searchQueryProvider).isEmpty ? null : ref.read(searchQueryProvider),
+ );
+ },
+ onHomeSelected: () {
+ debugPrint('📂 Home selected: resetting platform filter');
+ ref.read(isHomeSelectedProvider.notifier).state = true;
+ ref.read(selectedPlatformIdProvider.notifier).state = null;
+
+ // Load initial games page (all platforms)
+ ref.read(paginatedGamesProvider.notifier).reset();
+ ref.read(paginatedGamesProvider.notifier).loadInitial(
+ platformId: null,
+ search: ref.read(searchQueryProvider).isEmpty ? null : ref.read(searchQueryProvider),
+ );
+ },
+ ),
+ loading: () => const LinearProgressIndicator(),
+ error: (e, s) => Text('Error loading platforms: $e'),
),
- if (hasFilters)
- Positioned(
- right: 8,
- top: 8,
- child: Container(
- width: 8,
- height: 8,
- decoration: const BoxDecoration(
- color: Colors.deepPurple,
- shape: BoxShape.circle,
- ),
- ),
+ ),
+ if (directoryServiceAsync.value?.status.hasError == true)
+ SliverToBoxAdapter(
+ child: Container(
+ color: Colors.red.withValues(alpha: 0.1), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ child: Row(children: [const Icon(Icons.error_outline, color: Colors.red), const SizedBox(width: 12), Expanded(child: Text('${directoryServiceAsync.value!.status.message}', style: const TextStyle(color: Colors.white70, fontSize: 12))), TextButton(onPressed: () => ref.invalidate(directoryServiceProvider), child: const Text('Retry'))]),
),
- ],
- ),
- ],
- );
- },
- ),
- IconButton(
- icon: const Icon(Icons.shuffle),
- tooltip: 'Random game',
- onPressed: () async {
- final service = ref.read(rommServiceProvider);
- if (service == null) return;
- final game = await service.getRandomGame();
- if (game == null) return;
- if (context.mounted) _handleGameTap(context, ref, game);
- },
+ ),
+ if (rommService == null)
+ const SliverFillRemaining(child: Center(child: Text('Setup Required in Settings')))
+ else if (paginatedState.isLoading)
+ buildSkeletonSliverGrid(cardAspectRatio, gridColumnCount, cardSpacing, context, showTitle: showTitle)
+ else if (paginatedState.error != null)
+ SliverFillRemaining(child: Center(child: Text('Error: ${paginatedState.error}')))
+ else
+ Consumer(
+ builder: (context, ref, _) {
+ final isHome = ref.watch(isHomeSelectedProvider);
+ final displayGames = paginatedState.games;
+ final isSearching = ref.watch(searchQueryProvider).isNotEmpty;
+ if (isHome && !isSearching) {
+ return SliverList(
+ delegate: SliverChildListDelegate([
+ ref.watch(recentlyPlayedProvider).when(data: (games) => _buildHorizontalShelf(context: context, title: 'Continue Playing', icon: Icons.play_circle_outline, games: games, ref: ref, isFirst: true), error: (e, s) => const SizedBox.shrink(), loading: () => const SizedBox.shrink()),
+ ref.watch(recentlyAddedProvider).when(data: (games) => _buildHorizontalShelf(context: context, title: 'Recently Added', icon: Icons.new_releases_outlined, games: games, ref: ref), error: (e, s) => const SizedBox.shrink(), loading: () => const SizedBox.shrink()),
+ ref.watch(metadataCacheServiceProvider).when(data: (cache) {
+ final installed = cache.cachedGames.where((g) => downloadedCache[g.id] == true).toList()..sort((a, b) => a.displayName.compareTo(b.displayName));
+ return _buildHorizontalShelf(context: context, title: 'Installed Games (${installed.length})', icon: Icons.download_done, games: installed, ref: ref);
+ }, error: (e, s) => const SizedBox.shrink(), loading: () => const SizedBox.shrink()),
+ const SizedBox(height: 32),
+ ]),
+ );
+ }
+ if (displayGames.isEmpty) return const SliverFillRemaining(child: Center(child: Text('No games found')));
+ return SliverMainAxisGroup(
+ slivers: [
+ SliverToBoxAdapter(child: Padding(padding: const EdgeInsets.all(16.0), child: Text('Showing ${displayGames.length} of ${paginatedState.total} games', style: const TextStyle(fontSize: 12, color: Colors.grey)))),
+ SliverPadding(
+ padding: const EdgeInsets.all(12),
+ sliver: SliverGrid(
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: gridColumnCount, crossAxisSpacing: cardSpacing, mainAxisSpacing: cardSpacing, mainAxisExtent: calculateCardHeight(gridColumnCount, cardSpacing, cardAspectRatio, context, showTitle: showTitle)),
+ delegate: SliverChildBuilderDelegate(
+ (context, index) {
+ if (index == displayGames.length) return const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()));
+ final game = displayGames[index];
+ final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
+ final isDownloaded = (downloadedCache[game.id] ?? false) && !ref.watch(downloadProvider).containsKey(game.id);
+ return FocusEffectWrapper(
+ focusNode: index == 0 ? _firstContentItemFocusNode : null,
+ onTap: () => _handleGameTap(context, ref, game),
+ child: GameCard(game: game, coverUrl: coverUrl, isDownloaded: isDownloaded, platformLogoUrl: game.platformSlug != null ? '${ref.read(rommConfigProvider).value?.baseUrl ?? ''}/assets/platforms/${game.platformSlug}.svg' : null, showTitle: showTitle),
+ );
+ },
+ childCount: displayGames.length + (paginatedState.isLoadingMore ? 1 : 0),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ bottomNavigationBar: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 300),
+ transitionBuilder: (child, animation) => SlideTransition(position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), child: child),
+ child: ref.watch(inputModeProvider) != InputMode.mouse
+ ? const ControllerHintsBar(hints: [
+ ControllerHintItem(label: 'Select', button: 'A'),
+ ControllerHintItem(label: 'Filter', button: 'X'),
+ ControllerHintItem(label: 'Search', button: 'Y'),
+ ControllerHintItem(label: 'Prev Tab', button: 'L1'),
+ ControllerHintItem(label: 'Next Tab', button: 'R1'),
+ ])
+ : const SizedBox.shrink(key: ValueKey('hide_hints')),
+ ),
+ );
+ }
+
+ Widget _buildAppBarTitle(BuildContext context, WidgetRef ref, RommService? rommService) {
+ final theme = Theme.of(context);
+ final isOffline = rommService?.isOffline.value ?? false;
+ final headerMode = ref.watch(libraryHeaderTitleModeProvider);
+
+ // 1. If mode is "none", just show simple app title
+ if (headerMode == 'none') {
+ return Row(
+ children: [
+ const Text('Freegosy Launcher', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
+ if (isOffline) ...[
+ const SizedBox(width: 8),
+ _buildOfflineBadge(),
+ ],
+ ],
+ );
+ }
+
+ // 2. If mode is "server_ip", show unmasked server URL / IP
+ if (headerMode == 'server_ip') {
+ final baseUrl = rommService?.config.baseUrl ?? ref.watch(rommConfigProvider).value?.baseUrl ?? 'Loading...';
+ return Row(
+ children: [
+ Expanded(
+ child: Text(
+ isOffline ? "$baseUrl - Offline Mode" : baseUrl,
+ style: isOffline ? const TextStyle(color: Colors.orange, fontSize: 13, fontWeight: FontWeight.bold) : const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
+ overflow: TextOverflow.ellipsis,
+ ),
),
],
- ),
- body: KeyboardListener(
- focusNode: _focusNode,
- autofocus: true,
- onKeyEvent: (KeyEvent event) {
- if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.f5) {
- _refreshIndicatorKey.currentState?.show();
+ );
+ }
+
+ // 3. If mode is "greetings", show random legendary gaming quotes
+ if (headerMode == 'greetings') {
+ return _buildLegendaryQuote(isOffline);
+ }
+
+ // 4. If mode is "last_played", fetch from recentlyPlayedProvider
+ if (headerMode == 'last_played') {
+ final recentlyPlayed = ref.watch(recentlyPlayedProvider);
+ return recentlyPlayed.when(
+ data: (games) {
+ if (games.isEmpty) {
+ return _buildLegendaryQuote(isOffline);
}
- },
- child: ExcludeSemantics(
- child: Column(
+ final lastPlayed = games.first;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
children: [
- if (isSyncing)
- const LinearProgressIndicator(
- minHeight: 2,
- backgroundColor: Colors.transparent,
- ),
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
- child: TextField(
- controller: _searchController,
- decoration: InputDecoration(
- hintText: 'Search games...',
- prefixIcon: const Icon(Icons.search),
- border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ "LAST PLAYED GAME",
+ style: TextStyle(
+ fontSize: 9,
+ color: theme.colorScheme.primary.withValues(alpha: 0.8),
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.1,
+ ),
),
- onChanged: (value) {
- ref.read(searchQueryProvider.notifier).state = value;
- ref.read(paginatedGamesProvider.notifier).loadInitial(
- platformId: ref.read(selectedPlatformIdProvider)?.toString(),
- search: value.isEmpty ? null : value,
- );
- },
- ),
+ if (isOffline) ...[
+ const SizedBox(width: 8),
+ _buildOfflineBadge(),
+ ],
+ ],
),
- platformsAsync.when(
- data: (platforms) => PlatformFilterBar(
- platforms: platforms,
- selectedPlatformId: selectedPlatformId,
- isHome: isHomeSelected,
- onSelected: (platform) {
- ref.read(isHomeSelectedProvider.notifier).state = false;
- ref.read(selectedPlatformIdProvider.notifier).state = platform?.id;
- },
- onHomeSelected: () {
- ref.read(isHomeSelectedProvider.notifier).state = true;
- ref.read(selectedPlatformIdProvider.notifier).state = null;
- },
+ const SizedBox(height: 2),
+ Text(
+ lastPlayed.displayName,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.bold,
),
- loading: () => const LinearProgressIndicator(),
- error: (e, s) => Text('Error loading platforms: $e'),
+ overflow: TextOverflow.ellipsis,
),
- if (directoryServiceAsync.value?.status.hasError == true)
- Container(
- color: Colors.red.withValues(alpha: 0.1),
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
- child: Row(
- children: [
- const Icon(Icons.error_outline, color: Colors.red),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Text(
- 'Storage Error',
- style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
- ),
- Text(
- '${directoryServiceAsync.value!.status.message} (${directoryServiceAsync.value!.status.failedPath})',
- style: const TextStyle(color: Colors.white70, fontSize: 12),
- ),
- ],
- ),
- ),
- TextButton(
- onPressed: () {
- ref.invalidate(directoryServiceProvider);
- },
- child: const Text('Retry'),
- ),
- ],
+ ],
+ );
+ },
+ loading: () => const Text('Freegosy Launcher', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
+ error: (_, __) => _buildLegendaryQuote(isOffline),
+ );
+ }
+
+ // 5. Default: "daily" or "session" recommendation using cache
+ final cacheAsync = ref.watch(metadataCacheServiceProvider);
+ return cacheAsync.when(
+ data: (cache) {
+ final games = cache.cachedGames;
+ if (games.isEmpty) {
+ return _buildLegendaryQuote(isOffline);
+ }
+
+ final int seed;
+ final String label;
+ if (headerMode == 'session') {
+ seed = _sessionSeed;
+ label = "SESSION GAME RECOMMENDATION";
+ } else {
+ final now = DateTime.now();
+ seed = now.year * 10000 + now.month * 100 + now.day;
+ label = "TODAY'S GAME RECOMMENDATION";
+ }
+
+ final rng = Random(seed);
+ final randomIndex = rng.nextInt(games.length);
+ final recommendedGame = games[randomIndex];
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 9,
+ color: theme.colorScheme.primary.withValues(alpha: 0.8),
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.1,
),
),
- Expanded(
- child: rommService == null
- ? Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Icon(Icons.settings_outlined, size: 64, color: Colors.grey),
- const SizedBox(height: 24),
- const Text(
- 'Setup Required',
- style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
- ),
- const SizedBox(height: 8),
- const Text(
- 'Please configure your RomM server in Settings.',
- textAlign: TextAlign.center,
- style: TextStyle(color: Colors.grey),
- ),
- const SizedBox(height: 24),
- ElevatedButton(
- onPressed: () {
- ref.read(currentTabIndexProvider.notifier).state = 2;
- },
- child: const Text('Go to Settings'),
- ),
- ],
- ),
- )
- : paginatedState.isLoading
- ? buildSkeletonGrid(cardAspectRatio, gridColumnCount, cardSpacing, context, showTitle: showTitle)
- : (paginatedState.error != null && !paginatedState.error!.contains('Offline Mode'))
- ? Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Icon(Icons.error_outline, color: Colors.red, size: 48),
- const SizedBox(height: 16),
- Text(
- 'Error: ${paginatedState.error}',
- style: const TextStyle(color: Colors.red),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: () {
- ref.invalidate(rommServiceProvider);
- ref.read(paginatedGamesProvider.notifier).loadInitial(
- platformId: ref.read(selectedPlatformIdProvider)?.toString(),
- search: ref.read(searchQueryProvider),
- );
- },
- child: const Text('Retry'),
- ),
- ],
- ),
- )
- : RefreshIndicator(
- key: _refreshIndicatorKey,
- onRefresh: _refreshLibrary,
- child: Consumer(
- builder: (context, ref, _) {
- final isHome = ref.watch(isHomeSelectedProvider);
- final recentAsync = ref.watch(recentlyPlayedProvider);
- final metadataCacheAsync = ref.watch(metadataCacheServiceProvider);
- final displayGames = paginatedState.games;
-
- if (isHome) {
- return SingleChildScrollView(
- physics: const AlwaysScrollableScrollPhysics(),
- child: Column(
- children: [
- // 1. Continue Playing
- recentAsync.when(
- loading: () => const SizedBox.shrink(),
- error: (e, s) => const SizedBox.shrink(),
- data: (games) => _buildHorizontalShelf(
- context: context,
- title: 'Continue Playing',
- icon: Icons.play_circle_outline,
- games: games,
- ref: ref,
- ),
- ),
- // 2. Recently Added
- ref.watch(recentlyAddedProvider).when(
- loading: () => const SizedBox.shrink(),
- error: (e, s) => const SizedBox.shrink(),
- data: (games) => _buildHorizontalShelf(
- context: context,
- title: 'Recently Added',
- icon: Icons.new_releases_outlined,
- games: games,
- ref: ref,
- ),
- ),
- // 3. Installed Games
- metadataCacheAsync.when(
- loading: () => const SizedBox.shrink(),
- error: (e, s) => const SizedBox.shrink(),
- data: (cache) {
- // Filter games that are in our downloaded cache
- final installed = cache.cachedGames
- .where((g) => downloadedCache[g.id] == true)
- .toList();
-
- // Sort by name for now
- installed.sort((a, b) => a.displayName.compareTo(b.displayName));
-
- return _buildHorizontalShelf(
- context: context,
- title: 'Installed Games (${installed.length})',
- icon: Icons.download_done,
- games: installed,
- ref: ref,
- );
- },
- ),
- const SizedBox(height: 32),
- ],
- ),
- );
- }
+ if (isOffline) ...[
+ const SizedBox(width: 8),
+ _buildOfflineBadge(),
+ ],
+ ],
+ ),
+ const SizedBox(height: 2),
+ GestureDetector(
+ onTap: () => _handleGameTap(context, ref, recommendedGame),
+ child: Text(
+ recommendedGame.displayName,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.bold,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ );
+ },
+ loading: () => const Text('Freegosy Launcher', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
+ error: (_, __) => _buildLegendaryQuote(isOffline),
+ );
+ }
- if (displayGames.isEmpty) {
- return const Center(child: Text('No games found'));
- }
+ Widget _buildOfflineBadge() {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.orange.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(6),
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
+ ),
+ child: const Text(
+ 'OFFLINE',
+ style: TextStyle(color: Colors.orange, fontSize: 8, fontWeight: FontWeight.bold),
+ ),
+ );
+ }
- return CustomScrollView(
- controller: _scrollController,
- physics: const AlwaysScrollableScrollPhysics(),
- slivers: [
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
- child: Align(
- alignment: Alignment.centerRight,
- child: Text(
- 'Showing ${displayGames.length} of ${paginatedState.total} games',
- style: const TextStyle(fontSize: 12, color: Colors.grey),
- ),
- ),
- ),
- ),
- SliverPadding(
- padding: const EdgeInsets.all(12),
- sliver: SliverGrid(
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: gridColumnCount,
- crossAxisSpacing: cardSpacing,
- mainAxisSpacing: cardSpacing,
- mainAxisExtent: calculateCardHeight(gridColumnCount, cardSpacing, cardAspectRatio, context, showTitle: showTitle),
- ),
- delegate: SliverChildBuilderDelegate(
- (context, index) {
- if (index == displayGames.length) {
- return const Center(
- child: Padding(
- padding: EdgeInsets.all(16),
- child: CircularProgressIndicator(),
- ));
- }
- final game = displayGames[index];
- final downloads = ref.watch(downloadProvider);
- final isActuallyDownloading = downloads.containsKey(game.id);
- final isDownloaded = (downloadedCache[game.id] ?? false) && !isActuallyDownloading;
- final coverUrl = ref.read(rommServiceProvider)?.resolveCoverUrl(game);
-
- return GestureDetector(
- onTap: () => _handleGameTap(context, ref, game),
- child: GameCard(
- game: game,
- coverUrl: coverUrl,
- isDownloaded: isDownloaded,
- platformLogoUrl: game.platformSlug != null
- ? '${ref.read(rommConfigProvider).value?.baseUrl ?? ''}/assets/platforms/${game.platformSlug}.svg'
- : null,
- showTitle: showTitle,
- ),
- );
- },
- childCount: displayGames.length + (paginatedState.isLoadingMore ? 1 : 0),
- ),
- ),
- ),
- ],
- );
- },
- ),
- ),
+ Widget _buildLegendaryQuote(bool isOffline) {
+ final theme = Theme.of(context);
+ final now = DateTime.now();
+
+ // Split the 24-hour day into three 8-hour intervals (0: 00:00-07:59, 1: 08:00-15:59, 2: 16:00-23:59)
+ final interval = now.hour ~/ 8;
+ final seed = now.year * 100000 + now.month * 1000 + now.day * 10 + interval;
+
+ final rng = Random(seed);
+ final selected = GamingQuotes.quotes[rng.nextInt(GamingQuotes.quotes.length)];
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ "LEGENDARY GAMING QUOTE",
+ style: TextStyle(
+ fontSize: 9,
+ color: theme.colorScheme.primary.withValues(alpha: 0.8),
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.1,
),
+ ),
+ if (isOffline) ...[
+ const SizedBox(width: 8),
+ _buildOfflineBadge(),
],
+ ],
+ ),
+ const SizedBox(height: 2),
+ Text(
+ '"${selected.quote}" — ${selected.game}',
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.bold,
+ fontStyle: FontStyle.italic,
),
+ overflow: TextOverflow.ellipsis,
),
- ),
+ ],
);
}
}
diff --git a/lib/ui/screens/library_skeleton.dart b/lib/ui/screens/library_skeleton.dart
index 53e1e0f..668d5f9 100644
--- a/lib/ui/screens/library_skeleton.dart
+++ b/lib/ui/screens/library_skeleton.dart
@@ -16,6 +16,25 @@ double calculateCardHeight(int columnCount, double cardSpacing,
return totalHeight.clamp(80.0, 900.0);
}
+Widget buildSkeletonSliverGrid(
+ double cardAspectRatio, int columnCount, double cardSpacing, BuildContext context, {bool showTitle = true}) {
+ return SliverPadding(
+ padding: const EdgeInsets.all(12),
+ sliver: SliverGrid(
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: columnCount,
+ crossAxisSpacing: cardSpacing,
+ mainAxisSpacing: cardSpacing,
+ mainAxisExtent: calculateCardHeight(columnCount, cardSpacing, cardAspectRatio, context, showTitle: showTitle),
+ ),
+ delegate: SliverChildBuilderDelegate(
+ (context, index) => const _SkeletonCard(),
+ childCount: 20,
+ ),
+ ),
+ );
+}
+
Widget buildSkeletonGrid(
double cardAspectRatio, int columnCount, double cardSpacing, BuildContext context, {bool showTitle = true}) {
return GridView.builder(
diff --git a/lib/ui/screens/settings_custom_emulators_section.dart b/lib/ui/screens/settings_custom_emulators_section.dart
index 57ed745..3686c79 100644
--- a/lib/ui/screens/settings_custom_emulators_section.dart
+++ b/lib/ui/screens/settings_custom_emulators_section.dart
@@ -4,12 +4,106 @@ import 'package:file_picker/file_picker.dart';
import 'package:uuid/uuid.dart';
import '../../core/emulator/custom_emulator_config.dart';
import '../../providers/custom_emulators_provider.dart';
+import '../widgets/focus_effect_wrapper.dart';
+import '../widgets/dialog_back_bridge.dart';
+
+InputDecoration _buildInputDecoration(BuildContext context, String label, {String? hintText, String? helperText}) {
+ final theme = Theme.of(context);
+ return InputDecoration(
+ labelText: label,
+ hintText: hintText,
+ helperText: helperText,
+ labelStyle: TextStyle(color: theme.colorScheme.onSurfaceVariant),
+ helperStyle: TextStyle(color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), fontSize: 11),
+ filled: true,
+ fillColor: theme.colorScheme.surfaceContainerLowest.withValues(alpha: 0.5),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.2)),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(color: theme.colorScheme.outline.withValues(alpha: 0.2)),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ borderSide: BorderSide(color: theme.colorScheme.primary),
+ ),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ );
+}
+
+Widget _buildActionButton(
+ BuildContext context, {
+ required IconData icon,
+ required String label,
+ required VoidCallback? onTap,
+ bool isPrimary = false,
+ bool isDestructive = false,
+}) {
+ final theme = Theme.of(context);
+ return FocusEffectWrapper(
+ onTap: onTap,
+ borderRadius: 12.0,
+ scaleFactor: 1.05,
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ gradient: isPrimary
+ ? LinearGradient(
+ colors: [theme.colorScheme.primary, theme.colorScheme.primary.withValues(alpha: 0.8)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ )
+ : null,
+ color: isPrimary
+ ? null
+ : (isDestructive
+ ? Colors.red.withValues(alpha: 0.08)
+ : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3)),
+ border: Border.all(
+ color: isPrimary
+ ? theme.colorScheme.primary.withValues(alpha: 0.3)
+ : (isDestructive
+ ? Colors.red.withValues(alpha: 0.2)
+ : theme.colorScheme.outline.withValues(alpha: 0.3)),
+ width: 1.0,
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ icon,
+ size: 16,
+ color: isPrimary
+ ? theme.colorScheme.onPrimary
+ : (isDestructive ? Colors.redAccent : theme.colorScheme.onSurfaceVariant),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ color: isPrimary
+ ? theme.colorScheme.onPrimary
+ : (isDestructive ? Colors.redAccent : theme.colorScheme.onSurface),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
class SettingsCustomEmulatorsSection extends ConsumerWidget {
const SettingsCustomEmulatorsSection({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final theme = Theme.of(context);
final customEmulators = ref.watch(customEmulatorsProvider);
return Column(
@@ -22,51 +116,91 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
- color: Colors.orange.withValues(alpha: 0.2),
- borderRadius: BorderRadius.circular(4),
- border: Border.all(color: Colors.orange.withValues(alpha: 0.5)),
+ color: Colors.orange.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(6),
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.4)),
),
child: const Text(
'EXPERIMENTAL',
- style: TextStyle(color: Colors.orange, fontSize: 10, fontWeight: FontWeight.bold),
+ style: TextStyle(color: Colors.orange, fontSize: 9, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 8),
- const Text(
+ Text(
'Add your own emulators. Use comma-separated platform names (e.g. ps1,ps2). Custom emulators will appear in "Emulator Conflicts" below if they support the same platforms as built-in ones.',
- style: TextStyle(color: Colors.grey, fontSize: 13),
+ style: TextStyle(color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8), fontSize: 13),
),
const SizedBox(height: 16),
if (customEmulators.isEmpty)
- const Center(
+ Center(
child: Padding(
- padding: EdgeInsets.all(16.0),
- child: Text('No custom emulators added yet.', style: TextStyle(color: Colors.grey)),
+ padding: const EdgeInsets.all(16.0),
+ child: Text(
+ 'No custom emulators added yet.',
+ style: TextStyle(color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6)),
+ ),
),
),
- ...customEmulators.map((emu) => Card(
- margin: const EdgeInsets.only(bottom: 8),
- child: ListTile(
- leading: const Icon(Icons.settings_input_component),
- title: Text(emu.name),
- subtitle: Text('Platforms: ${emu.platforms.join(", ")}\nPath: ${emu.executablePath}'),
- trailing: IconButton(
- icon: const Icon(Icons.delete_outline, color: Colors.red),
- onPressed: () => ref.read(customEmulatorsProvider.notifier).removeEmulator(emu.id),
- ),
- isThreeLine: true,
+ ...customEmulators.map((emu) => Container(
+ margin: const EdgeInsets.only(bottom: 10),
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerLowest.withValues(alpha: 0.4),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.15)),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.settings_input_component, color: theme.colorScheme.primary, size: 24),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ emu.name,
+ style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ 'Platforms: ${emu.platforms.join(", ")}',
+ style: TextStyle(fontSize: 12, color: theme.colorScheme.onSurfaceVariant),
+ ),
+ Text(
+ 'Path: ${emu.executablePath}',
+ style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7)),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ FocusEffectWrapper(
+ onTap: () => ref.read(customEmulatorsProvider.notifier).removeEmulator(emu.id),
+ borderRadius: 12.0,
+ scaleFactor: 1.15,
+ child: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.error.withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: theme.colorScheme.error.withValues(alpha: 0.2)),
+ ),
+ child: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error),
+ ),
+ ),
+ ],
),
)),
const SizedBox(height: 12),
- SizedBox(
- width: double.infinity,
- child: FilledButton.icon(
- onPressed: () => _showAddEmulatorDialog(context, ref),
- icon: const Icon(Icons.add),
- label: const Text('Add Custom Emulator'),
- ),
+ _buildActionButton(
+ context,
+ icon: Icons.add,
+ label: 'Add Custom Emulator',
+ onTap: () => _showAddEmulatorDialog(context, ref),
+ isPrimary: true,
),
],
);
@@ -79,20 +213,23 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
final savePathController = TextEditingController();
final patternController = TextEditingController();
CustomSaveMethod saveMethod = CustomSaveMethod.file;
+ final theme = Theme.of(context);
showDialog(
context: context,
- builder: (context) => StatefulBuilder(
- builder: (context, setDialogState) => AlertDialog(
- title: const Row(
+ builder: (context) => DialogBackBridge(
+ child: StatefulBuilder(
+ builder: (context, setDialogState) => AlertDialog(
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ title: Row(
children: [
- Icon(Icons.add_to_queue),
- SizedBox(width: 12),
- Text('Add Custom Emulator'),
+ Icon(Icons.add_to_queue, color: theme.colorScheme.primary),
+ const SizedBox(width: 12),
+ const Text('Add Custom Emulator'),
],
),
content: Container(
- width: 600, // Fixed wider width for desktop/large screens
+ width: 600,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
@@ -106,20 +243,16 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
const SizedBox(height: 12),
TextField(
controller: nameController,
- decoration: const InputDecoration(
- labelText: 'Emulator Name',
- hintText: 'e.g. PCSX2 Nightly',
- border: OutlineInputBorder(),
- ),
+ decoration: _buildInputDecoration(context, 'Emulator Name', hintText: 'e.g. PCSX2 Nightly'),
),
const SizedBox(height: 16),
TextField(
controller: platformsController,
- decoration: const InputDecoration(
- labelText: 'Platform Slugs',
+ decoration: _buildInputDecoration(
+ context,
+ 'Platform Slugs',
hintText: 'e.g. ps1,ps2,psx',
helperText: 'Comma separated IDs used by RomM',
- border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
@@ -130,44 +263,47 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
Expanded(
child: TextField(
controller: exeController,
- decoration: const InputDecoration(
- labelText: 'Executable Path',
- border: OutlineInputBorder(),
- ),
+ decoration: _buildInputDecoration(context, 'Executable Path'),
readOnly: true,
),
),
- const SizedBox(width: 8),
- IconButton.filledTonal(
- icon: const Icon(Icons.folder_open),
- tooltip: 'Select Executable',
- onPressed: () async {
+ const SizedBox(width: 12),
+ FocusEffectWrapper(
+ onTap: () async {
FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
exeController.text = result.files.single.path ?? '';
}
},
+ borderRadius: 12.0,
+ scaleFactor: 1.1,
+ child: Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.2)),
+ ),
+ child: Icon(Icons.folder_open, color: theme.colorScheme.primary),
+ ),
),
],
),
- const SizedBox(height: 32),
+ const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
- const Text('Save Synchronization', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.deepPurpleAccent)),
- const SizedBox(height: 8),
- const Text('Configure how Freegosy should backup your saves.', style: TextStyle(color: Colors.grey, fontSize: 12)),
+ Text('Save Synchronization', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: theme.colorScheme.primary)),
+ const SizedBox(height: 4),
+ Text('Configure how Freegosy should backup your saves.', style: TextStyle(color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8), fontSize: 12)),
const SizedBox(height: 16),
DropdownButtonFormField<CustomSaveMethod>(
- initialValue: saveMethod,
+ value: saveMethod,
items: const [
DropdownMenuItem(value: CustomSaveMethod.file, child: Text('File Based (Single save file)')),
DropdownMenuItem(value: CustomSaveMethod.folder, child: Text('Folder Based (Syncs entire subfolder)')),
],
onChanged: (val) => setDialogState(() => saveMethod = val!),
- decoration: const InputDecoration(
- labelText: 'Sync Method',
- border: OutlineInputBorder(),
- ),
+ decoration: _buildInputDecoration(context, 'Sync Method'),
),
const SizedBox(height: 16),
Row(
@@ -175,24 +311,33 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
Expanded(
child: TextField(
controller: savePathController,
- decoration: const InputDecoration(
- labelText: 'Save Directory',
+ decoration: _buildInputDecoration(
+ context,
+ 'Save Directory',
helperText: 'Path to where the emulator stores saves',
- border: OutlineInputBorder(),
),
readOnly: true,
),
),
- const SizedBox(width: 8),
- IconButton.filledTonal(
- icon: const Icon(Icons.folder_open),
- tooltip: 'Select Directory',
- onPressed: () async {
+ const SizedBox(width: 12),
+ FocusEffectWrapper(
+ onTap: () async {
String? path = await FilePicker.platform.getDirectoryPath();
if (path != null) {
savePathController.text = path;
}
},
+ borderRadius: 12.0,
+ scaleFactor: 1.1,
+ child: Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.2)),
+ ),
+ child: Icon(Icons.folder_open, color: theme.colorScheme.primary),
+ ),
),
],
),
@@ -200,11 +345,11 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
const SizedBox(height: 16),
TextField(
controller: patternController,
- decoration: const InputDecoration(
- labelText: 'Save Pattern',
+ decoration: _buildInputDecoration(
+ context,
+ 'Save Pattern',
hintText: '*.srm or *.sav',
helperText: 'Use * for game name matching (e.g. *.srm)',
- border: OutlineInputBorder(),
),
),
],
@@ -218,8 +363,8 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
- ElevatedButton(
- onPressed: () {
+ FocusEffectWrapper(
+ onTap: () {
if (nameController.text.isEmpty || exeController.text.isEmpty || platformsController.text.isEmpty) {
return;
}
@@ -238,14 +383,26 @@ class SettingsCustomEmulatorsSection extends ConsumerWidget {
ref.read(customEmulatorsProvider.notifier).addEmulator(config);
Navigator.pop(context);
},
- style: ElevatedButton.styleFrom(
- minimumSize: const Size(100, 45),
+ borderRadius: 12.0,
+ scaleFactor: 1.05,
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ gradient: LinearGradient(
+ colors: [theme.colorScheme.primary, theme.colorScheme.primary.withValues(alpha: 0.8)],
+ ),
+ ),
+ child: Text(
+ 'Add Emulator',
+ style: TextStyle(color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold),
+ ),
),
- child: const Text('Add Emulator'),
),
],
),
),
- );
- }
+ ),
+ );
+}
}
diff --git a/lib/ui/screens/settings_display_section.dart b/lib/ui/screens/settings_display_section.dart
index 7fec7a5..e5d500e 100644
--- a/lib/ui/screens/settings_display_section.dart
+++ b/lib/ui/screens/settings_display_section.dart
@@ -1,8 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/library_provider.dart';
+import '../widgets/focus_effect_wrapper.dart';
+import '../widgets/dialog_back_bridge.dart';
+
+Widget _buildCustomDropdown<T>({
+ required BuildContext context,
+ required String label,
+ required T currentValue,
+ required String currentValueLabel,
+ required List<Map<String, dynamic>> items,
+ required Function(T) onChanged,
+}) {
+ final theme = Theme.of(context);
+ return FocusEffectWrapper(
+ onTap: () async {
+ final selected = await showDialog<T>(
+ context: context,
+ builder: (ctx) => DialogBackBridge(
+ child: AlertDialog(
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ title: Text('Select $label'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: items.map((item) {
+ final isSelected = item['value'] == currentValue;
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4.0),
+ child: FocusEffectWrapper(
+ onTap: () => Navigator.pop(ctx, item['value']),
+ borderRadius: 16.0,
+ autofocus: isSelected,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: isSelected
+ ? theme.colorScheme.primaryContainer.withValues(alpha: 0.4)
+ : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2),
+ border: Border.all(
+ color: isSelected
+ ? theme.colorScheme.primary.withValues(alpha: 0.4)
+ : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25),
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ isSelected ? Icons.check_circle : Icons.radio_button_off,
+ color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
+ size: 18,
+ ),
+ const SizedBox(width: 16),
+ Text(
+ item['label'] as String,
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: theme.colorScheme.onSurface,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }).toList(),
+ ),
+ actions: [
+ FocusEffectWrapper(
+ onTap: () => Navigator.pop(ctx),
+ borderRadius: 16.0,
+ useSafeScale: false,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.25),
+ border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.3)),
+ ),
+ child: Text('Cancel', style: TextStyle(color: theme.colorScheme.onSurfaceVariant)),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ if (selected != null) {
+ onChanged(selected);
+ }
+ },
+ borderRadius: 16.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: theme.colorScheme.surfaceContainerLowest.withValues(alpha: 0.5),
+ border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.15)),
+ ),
+ child: Row(
+ children: [
+ Text(
+ label,
+ style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: theme.colorScheme.onSurfaceVariant),
+ ),
+ const Spacer(),
+ Text(
+ currentValueLabel,
+ style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 13),
+ ),
+ const SizedBox(width: 8),
+ Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), size: 20),
+ ],
+ ),
+ ),
+ );
+}
+
+Widget _buildCustomToggleRow(
+ BuildContext context, {
+ required String title,
+ required String subtitle,
+ required bool value,
+ required Function(bool) onChanged,
+}) {
+ final theme = Theme.of(context);
+ return FocusEffectWrapper(
+ onTap: () => onChanged(!value),
+ borderRadius: 16.0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ color: theme.colorScheme.surfaceContainerLowest.withValues(alpha: 0.5),
+ border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.15)),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: theme.colorScheme.onSurfaceVariant)),
+ const SizedBox(height: 2),
+ Text(subtitle, style: TextStyle(color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), fontSize: 11)),
+ ],
+ ),
+ ),
+ AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: value ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: value ? theme.colorScheme.primary : theme.colorScheme.outline.withValues(alpha: 0.3),
+ ),
+ ),
+ child: Text(
+ value ? 'ON' : 'OFF',
+ style: TextStyle(
+ color: value ? theme.colorScheme.onPrimary : theme.colorScheme.onSurfaceVariant,
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
-// Function to build the Library Display section
Widget buildDisplaySection(
BuildContext context,
double cardAspectRatio,
@@ -12,86 +181,151 @@ Widget buildDisplaySection(
String activePreset,
WidgetRef ref,
) {
+ final theme = Theme.of(context);
+
+ // Find the closest standard shape value for dropdown selection
+ final shapes = [1.0, 0.72, 0.58];
+ final selectedShape = shapes.reduce((a, b) =>
+ (a - cardAspectRatio).abs() < (b - cardAspectRatio).abs() ? a : b);
+
+ // Find the closest standard spacing value for dropdown selection
+ final spacings = [4.0, 8.0, 12.0];
+ final selectedSpacing = spacings.reduce((a, b) =>
+ (a - cardSpacing).abs() < (b - cardSpacing).abs() ? a : b);
+
+ final presetLabels = {
+ 'windows_best': 'Windows',
+ 'steamdeck_best': 'Steam Deck',
+ 'cozy': 'Cozy',
+ 'compact': 'Compact',
+ 'custom': 'Custom',
+ };
+
+ final shapeLabels = {
+ 1.0: 'Square (1:1)',
+ 0.72: 'Portrait (3:4)',
+ 0.58: 'Tall (9:16)',
+ };
+
+ final spacingLabels = {
+ 4.0: 'Tight (4px)',
+ 8.0: 'Normal (8px)',
+ 12.0: 'Airy (12px)',
+ };
+
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),
+ _buildCustomDropdown<String>(
+ context: context,
+ label: 'Preset Layout',
+ currentValue: activePreset,
+ currentValueLabel: presetLabels[activePreset] ?? 'Custom',
+ items: const [
+ {'value': 'windows_best', 'label': 'Windows'},
+ {'value': 'steamdeck_best', 'label': 'Steam Deck'},
+ {'value': 'cozy', 'label': 'Cozy'},
+ {'value': 'compact', 'label': 'Compact'},
+ {'value': 'custom', 'label': 'Custom'},
],
+ onChanged: (presetKey) {
+ ref.read(activePresetProvider.notifier).update(presetKey);
+ if (presetKey == 'custom') return;
+ final preset = kDisplayPresets[presetKey];
+ if (preset == null) return;
+ ref.read(columnCountProvider.notifier).update(preset['columnCount'] as int);
+ ref.read(cardAspectRatioProvider.notifier).update(preset['cardAspectRatio'] as double);
+ ref.read(cardSpacingProvider.notifier).update(preset['cardSpacing'] as double);
+ ref.read(showTitleProvider.notifier).update(preset['showTitle'] as bool);
+ },
),
- 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)),
+ const SizedBox(height: 12),
+ _buildCustomDropdown<double>(
+ context: context,
+ label: 'Card Shape',
+ currentValue: selectedShape,
+ currentValueLabel: shapeLabels[selectedShape] ?? 'Portrait (3:4)',
+ items: const [
+ {'value': 1.0, 'label': 'Square (1:1)'},
+ {'value': 0.72, 'label': 'Portrait (3:4)'},
+ {'value': 0.58, 'label': 'Tall (9:16)'},
],
- ),
- Slider(
- value: columnCount.toDouble(),
- min: 2,
- max: 8,
- divisions: 6,
- label: '$columnCount',
- onChanged: (value) {
+ onChanged: (val) {
ref.read(activePresetProvider.notifier).update('custom');
- ref.read(columnCountProvider.notifier).update(value.toInt());
+ ref.read(cardAspectRatioProvider.notifier).update(val);
},
),
- 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')),
+ const SizedBox(height: 12),
+ _buildCustomDropdown<double>(
+ context: context,
+ label: 'Card Spacing',
+ currentValue: selectedSpacing,
+ currentValueLabel: spacingLabels[selectedSpacing] ?? 'Normal (8px)',
+ items: const [
+ {'value': 4.0, 'label': 'Tight (4px)'},
+ {'value': 8.0, 'label': 'Normal (8px)'},
+ {'value': 12.0, 'label': 'Airy (12px)'},
],
- selected: {
- [1.0, 0.72, 0.58].reduce((a, b) =>
- (a - cardAspectRatio).abs() < (b - cardAspectRatio).abs()
- ? a
- : b)
- },
- onSelectionChanged: (selection) {
+ onChanged: (val) {
ref.read(activePresetProvider.notifier).update('custom');
- ref.read(cardAspectRatioProvider.notifier).update(selection.first);
+ ref.read(cardSpacingProvider.notifier).update(val);
},
),
- const SizedBox(height: 16),
- const Text('Card Spacing', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
+ const SizedBox(height: 20),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ 'Columns per row',
+ style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: theme.colorScheme.onSurfaceVariant),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primary.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Text(
+ '$columnCount',
+ style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: theme.colorScheme.primary),
+ ),
+ ),
+ ],
+ ),
+ ),
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) {
- ref.read(activePresetProvider.notifier).update('custom');
- ref.read(cardSpacingProvider.notifier).update(selection.first);
- },
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: SliderTheme(
+ data: SliderTheme.of(context).copyWith(
+ activeTrackColor: theme.colorScheme.primary,
+ inactiveTrackColor: theme.colorScheme.outline.withValues(alpha: 0.2),
+ thumbColor: theme.colorScheme.primary,
+ overlayColor: theme.colorScheme.primary.withValues(alpha: 0.15),
+ valueIndicatorColor: theme.colorScheme.primary,
+ valueIndicatorTextStyle: TextStyle(color: theme.colorScheme.onPrimary),
+ ),
+ child: Slider(
+ value: columnCount.toDouble(),
+ min: 2,
+ max: 8,
+ divisions: 6,
+ label: '$columnCount',
+ onChanged: (value) {
+ ref.read(activePresetProvider.notifier).update('custom');
+ ref.read(columnCountProvider.notifier).update(value.toInt());
+ },
+ ),
+ ),
),
- const SizedBox(height: 16),
- SwitchListTile(
- title: const Text('Show game title'),
- subtitle: const Text('Display title text below cover art'),
+ const SizedBox(height: 8),
+ _buildCustomToggleRow(
+ context,
+ title: 'Show game title',
+ subtitle: 'Display title text below cover art',
value: showTitle,
- contentPadding: EdgeInsets.zero,
onChanged: (value) {
ref.read(activePresetProvider.notifier).update('custom');
ref.read(showTitleProvider.notifier).update(value);
@@ -100,31 +334,3 @@ Widget buildDisplaySection(
],
);
}
-
-// 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) {
- if (!selected) return;
- ref.read(activePresetProvider.notifier).update(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;
-
- ref.read(columnCountProvider.notifier).update(cols);
- ref.read(cardAspectRatioProvider.notifier).update(ratio);
- ref.read(cardSpacingProvider.notifier).update(spacing);
- ref.read(showTitleProvider.notifier).update(title);
- },
- );
-}
diff --git a/lib/ui/screens/settings_emulators_section.dart b/lib/ui/screens/settings_emulators_section.dart
index e8116e8..2f799ff 100644
--- a/lib/ui/screens/settings_emulators_section.dart
+++ b/lib/ui/screens/settings_emulators_section.dart
@@ -6,15 +6,107 @@ import 'package:file_picker/file_picker.dart';
import '../../core/storage/directory_service.dart';
import '../../core/storage/system_utils.dart';
import '../../core/emulator/emulator_registry_data.dart';
-
import '../../core/emulator/strategy_registry.dart';
import '../../core/emulator/firmware_service.dart';
import '../../ui/widgets/emulator_selection_dialog.dart';
import '../../providers/download_provider.dart';
import '../../providers/romm_provider.dart';
import '../../providers/library_provider.dart';
+import '../widgets/focus_effect_wrapper.dart';
+import '../widgets/dialog_back_bridge.dart';
+
+Widget _buildActionButton(
+ BuildContext context, {
+ required IconData icon,
+ required String label,
+ required VoidCallback? onTap,
+ bool isPrimary = false,
+ bool isDestructive = false,
+}) {
+ final theme = Theme.of(context);
+ return FocusEffectWrapper(
+ onTap: onTap,
+ borderRadius: 12.0,
+ scaleFactor: 1.05,
+ child: Container(
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ gradient: isPrimary
+ ? LinearGradient(
+ colors: [theme.colorScheme.primary, theme.colorScheme.primary.withValues(alpha: 0.8)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ )
+ : null,
+ color: isPrimary
+ ? null
+ : (isDestructive
+ ? Colors.red.withValues(alpha: 0.08)
+ : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3)),
+ border: Border.all(
+ color: isPrimary
+ ? theme.colorScheme.primary.withValues(alpha: 0.3)
+ : (isDestructive
+ ? Colors.red.withValues(alpha: 0.2)
+ : theme.colorScheme.outline.withValues(alpha: 0.3)),
+ width: 1.0,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ icon,
+ size: 14,
+ color: isPrimary
+ ? theme.colorScheme.onPrimary
+ : (isDestructive ? Colors.redAccent : theme.colorScheme.onSurfaceVariant),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ color: isPrimary
+ ? theme.colorScheme.onPrimary
+ : (isDestructive ? Colors.redAccent : theme.colorScheme.onSurface),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
+
+Widget _buildActionIconButton(
+ BuildContext context, {
+ required IconData icon,
+ required String tooltip,
+ required VoidCallback onTap,
+ Color? color,
+}) {
+ final theme = Theme.of(context);
+ return FocusEffectWrapper(
+ onTap: onTap,
+ borderRadius: 10.0,
+ scaleFactor: 1.15,
+ child: Tooltip(
+ message: tooltip,
+ child: Container(
+ padding: const EdgeInsets.all(6),
+ decoration: BoxDecoration(
+ color: (color ?? theme.colorScheme.primary).withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: (color ?? theme.colorScheme.primary).withValues(alpha: 0.2)),
+ ),
+ child: Icon(icon, size: 16, color: color ?? theme.colorScheme.primary),
+ ),
+ ),
+ );
+}
-// Function to build the Emulators section
Widget buildEmulatorsSection(
BuildContext context,
DirectoryService directoryService,
@@ -23,11 +115,11 @@ Widget buildEmulatorsSection(
Function(void Function()) setState,
WidgetRef ref,
) {
+ final theme = Theme.of(context);
final currentPlatform = defaultTargetPlatform == TargetPlatform.windows
? 'windows'
: (defaultTargetPlatform == TargetPlatform.macOS ? 'macos' : 'linux');
- // Filter emulators that are supported on the current OS
final supportedEmulators = kEmulatorDefinitions.where((def) {
final supported = def['supported_platforms'] as List<String>? ?? [];
return supported.contains(currentPlatform);
@@ -43,20 +135,22 @@ Widget buildEmulatorsSection(
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Row(
children: [
- IconButton(
- icon: const Icon(Icons.refresh),
- tooltip: 'Refresh installation status',
- onPressed: () {
+ _buildActionIconButton(
+ context,
+ icon: Icons.refresh,
+ tooltip: 'Refresh Status',
+ onTap: () {
ref.invalidate(emulatorStatusProvider);
},
+ color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
- ElevatedButton.icon(
- icon: const Icon(Icons.sync),
- label: const Text('Sync BIOS'),
- onPressed: () async {
- _syncAllBios(context, ref);
- },
+ _buildActionButton(
+ context,
+ icon: Icons.sync,
+ label: 'Sync BIOS',
+ onTap: () => _syncAllBios(context, ref),
+ isPrimary: true,
),
],
),
@@ -72,39 +166,81 @@ Widget buildEmulatorsSection(
final isInstalled = emulatorInstallStates[emulatorId] ?? false;
final overridePath = directoryService.getEmulatorPathOverride(emulatorId);
final type = def['type'] as String? ?? 'none';
- final canManage = type != 'none'; // Only github/direct emulators can be updated/uninstalled
-
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 8.0),
+ final canManage = type != 'none';
+
+ return Container(
+ padding: const EdgeInsets.all(12),
+ margin: const EdgeInsets.symmetric(vertical: 6),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerLowest.withValues(alpha: 0.4),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.15)),
+ ),
child: Row(
children: [
- Icon(
- isInstalled ? Icons.check_circle : Icons.cancel,
- color: isInstalled ? Colors.green : Colors.red,
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: isInstalled
+ ? Colors.green.withValues(alpha: 0.12)
+ : theme.colorScheme.error.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: isInstalled
+ ? Colors.green.withValues(alpha: 0.3)
+ : theme.colorScheme.error.withValues(alpha: 0.3),
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ isInstalled ? Icons.check_circle_outline : Icons.cancel_outlined,
+ color: isInstalled ? Colors.green : theme.colorScheme.error,
+ size: 14,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ isInstalled ? 'Installed' : 'Missing',
+ style: TextStyle(
+ color: isInstalled ? Colors.green : theme.colorScheme.error,
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
),
- const SizedBox(width: 8),
+ const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Text(emulatorName),
+ Text(
+ emulatorName,
+ style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
+ ),
if (overridePath != null)
- Text(
- overridePath,
- style: const TextStyle(fontSize: 11, color: Colors.grey),
- overflow: TextOverflow.ellipsis,
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: Text(
+ overridePath,
+ style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7)),
+ overflow: TextOverflow.ellipsis,
+ ),
),
],
),
),
if (emulatorId == 'rpcs3' && defaultTargetPlatform == TargetPlatform.macOS) ...[
- const Text('Arch: ', style: TextStyle(fontSize: 12, color: Colors.grey)),
+ const Text('Arch: ', style: TextStyle(fontSize: 11, color: Colors.grey)),
Consumer(
builder: (context, ref, child) {
final arch = ref.watch(rpcs3ArchitectureProvider);
return DropdownButton<String>(
value: arch,
- style: const TextStyle(fontSize: 12, color: Colors.blue),
+ style: TextStyle(fontSize: 11, color: theme.colorScheme.primary),
+ underline: const SizedBox(),
items: const [
DropdownMenuItem(value: 'x64', child: Text('x64')),
DropdownMenuItem(value: 'arm64', child: Text('ARM64')),
@@ -117,108 +253,162 @@ Widget buildEmulatorsSection(
);
},
),
+ const SizedBox(width: 8),
],
- if (emulatorId == 'eden') ...[
- // Build selection moved to download dialog
- ],
- 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
- }
- },
- ),
- IconButton(
- icon: const Icon(Icons.folder_shared, size: 20),
- tooltip: 'Open emulator folder',
- onPressed: () async {
- final path = await directoryService.getEmulatorDirectory(emulatorId);
- await SystemUtils.openDirectory(path);
- },
- ),
- IconButton(
- icon: const Icon(Icons.link, size: 20),
- tooltip: 'Download URL Override',
- onPressed: () => _showUrlOverrideDialog(context, ref, directoryService, emulatorId, type),
- ),
- IconButton(
- icon: const Icon(Icons.library_books, size: 20),
- tooltip: 'Sync BIOS from RomM',
- onPressed: () async {
- _syncBiosForEmulator(context, ref, emulatorId, emulatorName);
- },
- ),
- if (isInstalled) ...[
- IconButton(
- icon: const Icon(Icons.play_arrow, color: Colors.green),
- tooltip: "Launch Standalone",
- onPressed: () async {
- final messenger = ScaffoldMessenger.of(context);
- final strategy = ref
- .read(strategyRegistryProvider)
- .asData
- ?.value
- ?.getStrategyById(emulatorId);
- if (strategy != null) {
- try {
- await strategy.launchStandalone();
- } catch (e) {
- messenger.showSnackBar(
- SnackBar(content: Text("Failed to launch: $e")));
- }
- }
- },
- ),
- if (canManage) ...[
- IconButton(
- icon: const Icon(Icons.update, color: Colors.blue),
- tooltip: "Update Emulator",
- onPressed: () async {
- _startDownload(context, ref, emulatorId, emulatorName, emulatorInstallStates, setState);
- },
- ),
- IconButton(
- icon: const Icon(Icons.delete, color: Colors.red),
- tooltip: "Uninstall Emulator",
- onPressed: () async {
- final confirm = await showDialog<bool>(
- context: context,
- builder: (ctx) => AlertDialog(
- title: const Text("Uninstall Emulator"),
- content: Text("Are you sure you want to delete $emulatorName? This will remove all local files."),
- actions: [
- TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
- TextButton(
- style: TextButton.styleFrom(foregroundColor: Colors.red),
- onPressed: () => Navigator.pop(ctx, true),
- child: const Text("Uninstall"),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (isInstalled) ...[
+ _buildActionIconButton(
+ context,
+ icon: Icons.play_arrow,
+ tooltip: 'Launch Standalone',
+ onTap: () async {
+ final messenger = ScaffoldMessenger.of(context);
+ final strategy = ref
+ .read(strategyRegistryProvider)
+ .asData
+ ?.value
+ ?.getStrategyById(emulatorId);
+ if (strategy != null) {
+ try {
+ await strategy.launchStandalone();
+ } catch (e) {
+ messenger.showSnackBar(
+ SnackBar(content: Text("Failed to launch: $e")));
+ }
+ }
+ },
+ color: Colors.green,
+ ),
+ const SizedBox(width: 6),
+ _buildActionIconButton(
+ context,
+ icon: Icons.library_books,
+ tooltip: 'Sync BIOS',
+ onTap: () async {
+ _syncBiosForEmulator(context, ref, emulatorId, emulatorName);
+ },
+ ),
+ if (canManage) ...[
+ const SizedBox(width: 6),
+ _buildActionIconButton(
+ context,
+ icon: Icons.update,
+ tooltip: 'Update',
+ onTap: () async {
+ _startDownload(context, ref, emulatorId, emulatorName, emulatorInstallStates, setState);
+ },
+ color: Colors.blue,
+ ),
+ ],
+ ],
+ if (!isInstalled && canManage) ...[
+ _buildActionButton(
+ context,
+ icon: Icons.download,
+ label: 'Download',
+ onTap: () => _startDownload(context, ref, emulatorId, emulatorName, emulatorInstallStates, setState),
+ isPrimary: true,
+ ),
+ ],
+ const SizedBox(width: 6),
+ Theme(
+ data: theme.copyWith(
+ cardColor: theme.colorScheme.surfaceContainer,
+ ),
+ child: PopupMenuButton<String>(
+ icon: Icon(Icons.more_vert, color: theme.colorScheme.onSurfaceVariant),
+ onSelected: (val) async {
+ if (val == 'custom_dir') {
+ String? selectedDirectory = await FilePicker.platform.getDirectoryPath();
+ if (selectedDirectory != null) {
+ await directoryService.setEmulatorPathOverride(emulatorId, selectedDirectory);
+ setState(() {});
+ }
+ } else if (val == 'open_folder') {
+ final path = await directoryService.getEmulatorDirectory(emulatorId);
+ await SystemUtils.openDirectory(path);
+ } else if (val == 'url_override') {
+ _showUrlOverrideDialog(context, ref, directoryService, emulatorId, type);
+ } else if (val == 'uninstall') {
+ final confirm = await showDialog<bool>(
+ context: context,
+ builder: (ctx) => DialogBackBridge(
+ child: AlertDialog(
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ title: const Text("Uninstall Emulator"),
+ content: Text("Are you sure you want to delete $emulatorName? This will remove all local files."),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")),
+ TextButton(
+ style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
+ onPressed: () => Navigator.pop(ctx, true),
+ child: const Text("Uninstall"),
+ ),
+ ],
+ ),
),
- ],
- ),
- );
-
- if (confirm == true) {
- await directoryService.deleteEmulator(emulatorId);
- ref.invalidate(emulatorStatusProvider);
- if (context.mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text("$emulatorName uninstalled.")),
);
+
+ if (confirm == true) {
+ await directoryService.deleteEmulator(emulatorId);
+ ref.invalidate(emulatorStatusProvider);
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text("$emulatorName uninstalled.")),
+ );
+ }
+ }
}
- }
- },
+ },
+ itemBuilder: (ctx) => [
+ PopupMenuItem(
+ value: 'custom_dir',
+ child: Row(
+ children: [
+ Icon(Icons.folder_open, size: 16, color: theme.colorScheme.onSurfaceVariant),
+ const SizedBox(width: 8),
+ const Text('Set Custom Directory', style: TextStyle(fontSize: 13)),
+ ],
+ ),
+ ),
+ PopupMenuItem(
+ value: 'open_folder',
+ child: Row(
+ children: [
+ Icon(Icons.folder_shared, size: 16, color: theme.colorScheme.onSurfaceVariant),
+ const SizedBox(width: 8),
+ const Text('Open Emulator Folder', style: TextStyle(fontSize: 13)),
+ ],
+ ),
+ ),
+ PopupMenuItem(
+ value: 'url_override',
+ child: Row(
+ children: [
+ Icon(Icons.link, size: 16, color: theme.colorScheme.onSurfaceVariant),
+ const SizedBox(width: 8),
+ const Text('Download URL Override', style: TextStyle(fontSize: 13)),
+ ],
+ ),
+ ),
+ if (isInstalled && canManage)
+ PopupMenuItem(
+ value: 'uninstall',
+ child: Row(
+ children: [
+ Icon(Icons.delete, size: 16, color: theme.colorScheme.error),
+ const SizedBox(width: 8),
+ Text('Uninstall', style: TextStyle(color: theme.colorScheme.error, fontSize: 13)),
+ ],
+ ),
+ ),
+ ],
+ ),
),
],
- ],
- if (!isInstalled && canManage)
- ElevatedButton(
- onPressed: () => _startDownload(context, ref, emulatorId, emulatorName, emulatorInstallStates, setState),
- child: const Text('Download'),
- ),
+ ),
],
),
);
@@ -233,91 +423,92 @@ Future<void> _startDownload(
String emulatorId,
String emulatorName,
Map<String, bool> emulatorInstallStates,
- Function(void Function()) setState,
-) async {
+ Function(void Function()) setState, {
+ String? urlOverride,
+}) async {
final messenger = ScaffoldMessenger.of(context);
String? architecture;
String? buildType;
- if (emulatorId == 'rpcs3' && defaultTargetPlatform == TargetPlatform.macOS) {
- architecture = ref.read(rpcs3ArchitectureProvider);
- }
+ if (urlOverride == null) {
+ if (emulatorId == 'rpcs3' && defaultTargetPlatform == TargetPlatform.macOS) {
+ architecture = ref.read(rpcs3ArchitectureProvider);
+ }
- if (emulatorId == 'eden') {
- if (!context.mounted) return;
- final choice = await showDialog<String>(
- context: context,
- builder: (ctx) => AlertDialog(
- title: const Text("Select Eden Build"),
- content: const Text("Choose which version of Eden to install:"),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(ctx, 'nightly'),
- child: const Text("Nightly (Experimental)"),
- ),
- TextButton(
- onPressed: () => Navigator.pop(ctx, 'stable'),
- child: const Text("Stable (Recommended)"),
+ if (emulatorId == 'eden') {
+ if (!context.mounted) return;
+ final choice = await showDialog<String>(
+ context: context,
+ builder: (ctx) => DialogBackBridge(
+ child: AlertDialog(
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ title: const Text("Select Eden Build"),
+ content: const Text("Choose which version of Eden to install:"),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx, 'nightly'),
+ child: const Text("Nightly (Experimental)"),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx, 'stable'),
+ child: const Text("Stable (Recommended)"),
+ ),
+ ],
),
- ],
- ),
- );
+ ),
+ );
- if (choice == null) return; // Cancelled
- buildType = choice;
- ref.read(edenBuildTypeProvider.notifier).update(buildType);
+ if (choice == null) return;
+ buildType = choice;
+ ref.read(edenBuildTypeProvider.notifier).update(buildType);
+ }
}
final String buildLabel = (buildType != null && buildType != 'stable') ? " ($buildType)" : "";
- messenger.showSnackBar(SnackBar(
- content: Text('Starting download for $emulatorName${architecture != null ? " ($architecture)" : ""}$buildLabel...'),
- ));
+ if (urlOverride == null) {
+ messenger.showSnackBar(SnackBar(
+ content: Text('Starting download for $emulatorName${architecture != null ? " ($architecture)" : ""}$buildLabel...'),
+ ));
+ }
ref.read(downloadProvider.notifier).startEmulatorDownload(
emulatorId,
emulatorName,
architecture: architecture,
buildType: buildType,
+ urlOverride: urlOverride,
);
- // Listen for download completion or selection requirement
StreamSubscription? sub;
sub = ref.read(downloadProvider.notifier).stream.listen((downloads) async {
final progress = downloads[emulatorId];
if (progress == null) return;
if (progress.status == 'selection_required') {
- sub?.cancel(); // Cancel current listener as we are entering a modal flow
-
- // We need to fetch the assets again or retrieve from the provider.
- // Since it's a new flow, we fetch them via the ReleaseService directly or via a provider.
- // Given we are in the UI, we can use the ReleaseService if available,
- // but the assets are likely already fetched in the download service.
- // Since we don't have direct access to the asset list in DownloadProgress,
- // let's assume we re-fetch them based on definition.
+ sub?.cancel();
- final assets = await ref.read(emulatorDownloadServiceProvider).value!.getLatestAssetsForEmulator(emulatorId);
+ final service = await ref.read(emulatorDownloadServiceProvider.future);
+ if (service == null) return;
+ final assets = await service.getLatestAssetsForEmulator(emulatorId);
if (context.mounted) {
final selected = await showDialog<Map<String, String>>(
context: context,
- builder: (ctx) => EmulatorSelectionDialog(
- assets: assets,
- onSelect: (url) => Navigator.pop(ctx, assets.firstWhere((a) => a['url'] == url)),
+ builder: (ctx) => DialogBackBridge(
+ child: EmulatorSelectionDialog(
+ assets: assets,
+ onSelect: (url) => Navigator.pop(ctx, assets.firstWhere((a) => a['url'] == url)),
+ ),
),
);
if (selected != null) {
- // Restart download with the selected URL
- messenger.showSnackBar(SnackBar(content: Text('Downloading ${selected['name']}...')));
- ref.read(downloadProvider.notifier).startEmulatorDownload(
- emulatorId,
- emulatorName,
- urlOverride: selected['url'],
- );
+ if (context.mounted) {
+ _startDownload(context, ref, emulatorId, emulatorName, emulatorInstallStates, setState, urlOverride: selected['url']);
+ }
}
}
- return; // Stop processing further for this progress update
+ return;
}
if (progress.isComplete || progress.error != null) {
@@ -337,8 +528,8 @@ Future<void> _startDownload(
});
}
-// Function to build the Emulator Conflicts section
Widget buildConflictsSection(
+ BuildContext context,
StrategyRegistry registry,
Function(void Function()) setState,
) {
@@ -350,7 +541,7 @@ Widget buildConflictsSection(
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
if (conflicts.isEmpty)
- const Text('No conflicts detected')
+ const Text('No conflicts detected', style: TextStyle(color: Colors.grey))
else
...conflicts.entries.map((entry) {
final slug = entry.key;
@@ -365,7 +556,7 @@ Widget buildConflictsSection(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Text(slug, style: const TextStyle(fontWeight: FontWeight.w500)),
+ Text(slug, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(strategies.map((s) => s.name).join(' vs '),
style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
@@ -373,6 +564,7 @@ Widget buildConflictsSection(
),
DropdownButton<String>(
value: currentStrategy?.emulatorId,
+ underline: const SizedBox(),
items: strategies.map((s) {
return DropdownMenuItem(
value: s.emulatorId,
@@ -382,7 +574,7 @@ Widget buildConflictsSection(
onChanged: (value) async {
if (value != null) {
await registry.setPreference(slug, value);
- setState(() {}); // Trigger parent state update
+ setState(() {});
}
},
),
@@ -391,15 +583,19 @@ Widget buildConflictsSection(
);
}),
const SizedBox(height: 16),
- ElevatedButton(
- style: ElevatedButton.styleFrom(
- foregroundColor: Colors.red,
- ),
- onPressed: () async {
- await registry.clearPreferences();
- setState(() {});
- },
- child: const Text('Reset Emulator Preferences'),
+ Row(
+ children: [
+ _buildActionButton(
+ context,
+ icon: Icons.restore_page,
+ label: 'Reset Emulator Preferences',
+ onTap: () async {
+ await registry.clearPreferences();
+ setState(() {});
+ },
+ isDestructive: true,
+ ),
+ ],
),
],
);
@@ -439,60 +635,66 @@ void _syncBiosForEmulator(BuildContext context, WidgetRef ref, String emulatorId
void _showUrlOverrideDialog(BuildContext context, WidgetRef ref, DirectoryService directoryService, String emulatorId, String type) {
final controller = TextEditingController();
+ final theme = Theme.of(context);
- // Only load the stored override (instant SharedPreferences read, no network)
directoryService.getEmulatorUrlOverride(emulatorId).then((stored) {
controller.text = stored ?? '';
});
showDialog(
context: context,
- builder: (ctx) => AlertDialog(
- title: const Text("Download URL Override"),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- "Source: $type — paste a direct download URL to override",
- style: const TextStyle(fontSize: 12, color: Colors.grey),
- ),
- const SizedBox(height: 16),
- TextField(
- controller: controller,
- decoration: const InputDecoration(
- labelText: "Override URL",
- hintText: "https://example.com/emulator.zip",
- helperText: "Accepts .zip .7z .dmg .tar.gz .AppImage",
+ builder: (ctx) => DialogBackBridge(
+ child: AlertDialog(
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
+ title: const Text("Download URL Override"),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Source: $type — paste a direct download URL to override",
+ style: const TextStyle(fontSize: 12, color: Colors.grey),
),
+ const SizedBox(height: 16),
+ TextField(
+ controller: controller,
+ decoration: InputDecoration(
+ labelText: "Override URL",
+ hintText: "https://example.com/emulator.zip",
+ helperText: "Accepts .zip .7z .dmg .tar.gz .AppImage",
+ filled: true,
+ fillColor: theme.colorScheme.surfaceContainerLowest.withValues(alpha: 0.5),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ OutlinedButton(
+ onPressed: () async {
+ await directoryService.setEmulatorUrlOverride(emulatorId, null);
+ controller.text = '';
+ if (ctx.mounted) Navigator.pop(ctx);
+ },
+ child: const Text("Reset"),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text("Cancel"),
+ ),
+ FilledButton(
+ onPressed: () async {
+ final text = controller.text.trim();
+ await directoryService.set