diff --git a/Cargo.lock b/Cargo.lock index f041e7c..ef33028 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colony-ui" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31984ffb00f64c0fd0b998103ef11e1f4255e8f26c784a3776caf4b9ea28d60b" +dependencies = [ + "dirs 6.0.0", + "iced", + "serde", + "serde_json", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1028,6 +1040,15 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "7.0.0" @@ -4076,6 +4097,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -4325,8 +4347,9 @@ name = "spotter" version = "0.3.0" dependencies = [ "chrono", + "colony-ui", "csv", - "dirs", + "dirs 7.0.0", "iced", "keyring", "keyring-core", diff --git a/Cargo.toml b/Cargo.toml index 14bc0e0..e512dbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ license = "GPL-3.0-or-later" [dependencies] iced = { version = "0.14.0", features = ["image"] } +# The shared Colony design system: themes, fonts, labels, preferences, paths. +# Built on iced 0.14 exactly, so the two move together. +colony-ui = "0.1.6" ureq = { version = "3.4.2", features = ["json"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" diff --git a/README.md b/README.md index 6d63454..1e02cb8 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ A cross-platform game library tracker and statistics dashboard built with [Rust] - **Multi-platform imports** - Steam, GOG, Epic Games, Xbox (via OpenXBL), and PlayStation Network - **Achievement tracking** - view and track achievements with icons, descriptions, and unlock dates - **Statistics dashboard** - playtime charts, status/platform distribution, most played games -- **Customizable UI** - dark/darker/midnight themes, accent colors, UI scaling, sidebar width, compact mode -- **Accessibility** - high contrast mode, large click targets, status label toggles -- **Settings** - date format, notification preferences, toast duration, start screen, default status/platform +- **Customizable UI** - the shared Colony themes and accents, two text sizes, sidebar width, compact mode +- **Accessibility** - high contrast, a dyslexia-friendly font, reduced motion, large click targets, status labels +- **Preferences** - language, date format, notifications, start screen, default status/platform +- **English and French** - the interface follows the system language, or the one picked in Preferences - **Local-first** - all data stored in a local SQLite database, no cloud dependency ## Running the binaries @@ -78,9 +79,12 @@ src/ lib.rs Module tree; public modules for tests app.rs Application state, messages, update loop db.rs SQLite database operations + paths.rs Where Spotter's files live, and the copy from Spotter 0.3 + preferences.rs preferences.json: load, save, import from Spotter 0.3 keyring.rs Platform tokens in the OS keyring - models.rs Data structures (Game, Platform, Settings, etc.) - theme.rs Color constants and ViewTheme + models.rs Data structures (Game, Platform, Preferences, etc.) + i18n/ Interface strings: en.json, fr.json and the lookup + theme.rs Icons and style helpers on the Colony palette steam.rs Steam import + HTML scraping steam_auth.rs Steam browser login flow gog.rs GOG Galaxy import @@ -88,19 +92,29 @@ src/ xbox.rs Xbox Live import via OpenXBL playstation.rs PSN import via trophy API images.rs Cover art + achievement icon caching - views/ UI views (library, detail, stats, settings, etc.) + views/ UI views (library, detail, stats, preferences, etc.) tests/ unit_tests.rs Unit + integration tests ``` ## Data Storage -All data is stored locally: -- **Database**: `~/.local/share/spotter/spotter.db` (Linux) or equivalent `dirs::data_dir()` -- **Covers**: `~/.local/share/spotter/covers/` -- **Achievement icons**: `~/.local/share/spotter/achievement_icons/` -- **Settings**: Stored as JSON inside the SQLite database -- **Platform credentials** (API keys, login tokens): in the operating system's keyring (Windows Credential Manager, macOS Keychain, the Secret Service on Linux). Without one, they stay unencrypted in the database and the Profile page says so. +All data is stored locally, under `Colony/Spotter/` in the platform's folders: + +| | Linux | Windows | macOS | +|---|---|---|---| +| Config | `~/.config/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | `~/Library/Application Support/Colony/Spotter/` | +| Data | `~/.local/share/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | `~/Library/Application Support/Colony/Spotter/` | +| Cache | `~/.cache/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\cache\` | `~/Library/Caches/Colony/Spotter/` | + +- **Preferences**: `/preferences/preferences.json` +- **Library database**: `/library/spotter.db` (SQLite) +- **Exports and the automatic backup**: `/exports/` +- **Import log**: `/logs/import.log` +- **Covers and achievement icons**: `/covers/` and `/achievement_icons/`, downloaded again when deleted +- **Platform credentials** (API keys, login tokens): in the operating system's keyring (Windows Credential Manager, macOS Keychain, the Secret Service on Linux). Without one, they are kept unencrypted in `/credentials/tokens.json`, readable by your user only, and the Profile page says so. + +Spotter 0.3 and earlier kept everything in the config folder (`data/`, `exports/`, `logs/`, `cache/`). The first start of a newer Spotter copies the library, the exports and the log to the folders above, imports the settings from the old database into `preferences.json`, and leaves the old files where they are. ## Code signing policy @@ -115,7 +129,7 @@ Team roles and members: ### Privacy policy -Spotter has no telemetry, no analytics and no update check, and sends nothing to its developers. Your library stays in the local database. Your platform credentials (the API keys you enter and the login tokens Spotter receives) are kept in your operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux, such as GNOME Keyring or KWallet); when no keyring is available, Spotter keeps them unencrypted in the local database instead, and says so on its Profile page. Spotter only contacts the services below, for the features that need them: +Spotter has no telemetry, no analytics and no update check, and sends nothing to its developers. Your library stays in the local database. Your platform credentials (the API keys you enter and the login tokens Spotter receives) are kept in your operating system's keyring (Windows Credential Manager, macOS Keychain, or the Secret Service on Linux, such as GNOME Keyring or KWallet); when no keyring is available, Spotter keeps them unencrypted in a file of its config folder that only your user can read instead, and says so on its Profile page. Spotter only contacts the services below, for the features that need them: - **Imports, when you start one** from the Import screen. Spotter contacts the platform you picked and sends it the credentials you entered for it, plus the ids of your games: - Steam: `api.steampowered.com` (your Web API key and Steam ID, to list owned games), `steamcommunity.com` (your Steam ID, to read achievements from your profile) and `store.steampowered.com` (game details). @@ -124,7 +138,7 @@ Spotter has no telemetry, no analytics and no update check, and sends nothing to - Xbox: `xbl.io` (your OpenXBL API key). - PlayStation: `ca.account.sony.com` (your NPSSO token, exchanged for an access token), then `m.np.playstation.com` with that token. - **Steam login**, when you use it: Spotter opens Steam's sign-in page in your browser, receives the answer on a local port of your own computer (127.0.0.1) and asks `steamcommunity.com` to confirm it. -- **Cover art and achievement icons**, downloaded from the image addresses stored with each game, which come from those platforms (or from a JSON backup you import): covers after an import, unless you turn off automatic cover downloads in Settings; achievement icons when you open a Steam game's page. +- **Cover art and achievement icons**, downloaded from the image addresses stored with each game, which come from those platforms (or from a JSON backup you import): covers after an import, unless you turn off automatic cover downloads in Preferences; achievement icons when you open a Steam game's page. - **At startup**, if you have connected GOG or Epic Games, Spotter renews that login automatically by sending the saved refresh token to `auth.gog.com` or `account-public-service-prod.ol.epicgames.com`. Links such as a game's store page open in your browser; Spotter itself sends nothing for them. diff --git a/assets/fonts/JetBrainsMonoNerdFont-Bold.ttf b/assets/fonts/JetBrainsMonoNerdFont-Bold.ttf deleted file mode 100644 index 8ccb8eb..0000000 Binary files a/assets/fonts/JetBrainsMonoNerdFont-Bold.ttf and /dev/null differ diff --git a/assets/fonts/JetBrainsMonoNerdFont-Regular.ttf b/assets/fonts/JetBrainsMonoNerdFont-Regular.ttf deleted file mode 100644 index b8910ab..0000000 Binary files a/assets/fonts/JetBrainsMonoNerdFont-Regular.ttf and /dev/null differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ee2b762..7c88cb3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,14 +12,17 @@ app.rs (1300+ lines) State machine: Spotter struct, Message enum, update(), v │ ├── library.rs Game list with search, filters, sort │ ├── detail.rs Single game: metadata, achievements, rating, notes │ ├── stats.rs Statistics dashboard with charts -│ ├── settings.rs Three-tab preferences (General / Appearances / Accessibility) +│ ├── preferences.rs Preferences page on the shared Colony kit │ ├── profile.rs User profile, credentials, data export/import │ ├── import.rs Platform import cards with step-by-step flows │ ├── sidebar.rs Navigation + quick stats │ └── add_game.rs Manual game entry form -├── models.rs Data structures: Game, Settings, Platform, Achievement, etc. +├── models.rs Data structures: Game, Preferences, Platform, Achievement, etc. +├── i18n/ English and French strings (en.json, fr.json) and t/tf/tn ├── db.rs SQLite: schema, CRUD, playtime history, export/import -├── theme.rs Color constants, ViewTheme, style helpers +├── paths.rs File locations (colony_ui::paths) and the copy from Spotter 0.3 +├── preferences.rs preferences.json: lenient load, save, import from Spotter 0.3 +├── theme.rs Icons and style helpers on the Colony palette ├── error.rs SpotterError enum (migration in progress) ├── api_client.rs HTTP agents, retry logic, URL encoding, browser open ├── images.rs Cover art + achievement icon download/caching @@ -67,7 +70,7 @@ All state mutations pass through the `Message` enum in `app.rs`. Async operation │ Profile ────┼───►│ Profile (credentials, export) │ │ │ │ │ │ ── Quick ── │ │ GameDetail (from library click) │ -│ Playing: X │ │ Settings (from sidebar logo) │ +│ Playing: X │ │ Preferences (from the Spotter name)│ │ Completed: Y│ │ │ │ Unplayed: Z │ └────────────────────────────────────┘ │ │ @@ -112,15 +115,15 @@ Existing user data (ratings, notes, status) is always preserved. Only empty meta ### Theme System -Two layers: -- **Static constants** (`theme.rs`): Used in style closures where `ViewTheme` isn't available -- **ViewTheme struct**: Computed per-render from `Settings`, provides theme-mode-aware colors +Colours come from colony-ui. `StandardPreferences::apply` sets the theme, accent and high contrast at startup and on every change; widgets read the active palette wherever they are drawn, including inside style closures: ```rust -let vt = ViewTheme::from_settings(&app.settings); -// vt.bg_card, vt.text_muted, vt.border, etc. +text(label).color(Palette::TEXT_MUTED()) +container(content).style(theme::card_style(Palette::BG_CARD(), Palette::BORDER_SUBTLE(), 8.0)) ``` +Platforms and game statuses keep one colour on every theme: a shared accent swatch picked by key (`Platform::accent_key`, `GameStatus::accent_key`). + ## Dependencies | Crate | Version | Role | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 38386a4..8e358cd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,100 +1,116 @@ # Configuration -All settings are stored as a JSON blob in the SQLite `settings` table and applied immediately on change. +All preferences are saved to `preferences.json` in Spotter's config folder (`~/.config/Colony/Spotter/preferences/preferences.json` on Linux; see [Data Storage](../README.md#data-storage) for the other platforms) and applied immediately on change. There is no Save button. -## Settings Overview +## The Preferences page + +Clicking **Spotter** at the top of the sidebar opens Preferences in place of the current screen; clicking it again, the page's Close button or Esc goes back to that screen. The page is the shared Colony one (`colony_ui::widgets`): General, Appearance and Accessibility come first, then Spotter's own Library and Imports. Every section starts collapsed. ### General | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `default_status` | GameStatus | Unplayed | Default status for new games | -| `default_platform` | Platform | Steam | Default platform for new games | -| `confirm_before_delete` | bool | true | Show confirmation dialog before deleting a game | +| `language` | `"en"`, `"fr"` or none | none | Interface language; none follows the system (`LC_ALL`, `LC_MESSAGES`, then `LANG`) | | `start_screen` | StartScreen | Library | Screen shown on launch (Library / Statistics / Import / Profile) | -| `notifications_enabled` | bool | true | Show toast notifications for success messages | -| `toast_duration` | ToastDuration | Normal (4s) | Auto-dismiss duration: Short (2s), Normal (4s), Long (8s) | +| `notifications_enabled` | bool | true | Show toast notifications for success messages (errors always show) | +| `toast_duration` | ToastDuration | Normal (4 s) | Auto-dismiss duration: Short (2 s), Normal (4 s), Long (8 s) | | `date_format` | DateFormat | YYYY-MM-DD | Date display: YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY | -| `default_sort_order` | SortOrder | Title A-Z | Default library sort | -| `remember_filters` | bool | false | Persist filter selections across sessions | -| `show_game_descriptions` | bool | false | Show store descriptions in library cards | -| `achievements_display` | AchievementsDisplay | Full | Full list / Summary only / Hidden | -| `download_covers_auto` | bool | true | Automatically download cover art on import | -### Appearances +### Appearance + +Theme, accent, text size, effects and a live preview come from the shared kit (see below). Spotter adds a Layout section: | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `theme_mode` | ThemeMode | Dark | Dark / Darker / Midnight | -| `accent_color` | AccentColor | Blue | Blue / Purple / Green / Gold / Red / Cyan | +| `sidebar_width` | u16 | 220 | Sidebar width in pixels (160-400, presets: 180/220/280) | | `compact_list` | bool | false | Reduced spacing in game list (4px vs 8px) | | `show_covers_in_list` | bool | true | Show cover thumbnails in library | -| `ui_scale` | UiScale | Normal | Small (0.85x) / Normal (1.0x) / Large (1.2x) | -| `sidebar_width` | u16 | 220 | Sidebar width in pixels (160-400, presets: 180/220/280) | ### Accessibility +Vision, Motion and Reading come from the shared kit. Spotter adds Color cues and Navigation: + | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `high_contrast` | bool | false | Increase text/border contrast | | `show_status_labels` | bool | true | Show text labels alongside status dots | -| `large_click_targets` | bool | false | Increase button padding (8px → 12px) | +| `large_click_targets` | bool | false | Increase button padding (8px to 12px) | + +### Library + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `default_status` | GameStatus | Unplayed | Default status for new games | +| `default_platform` | Platform | Steam | Default platform for new games | +| `default_sort_order` | SortOrder | Title A-Z | Default library sort | +| `remember_filters` | bool | true | Persist filter selections across sessions | +| `show_game_descriptions` | bool | false | Show store descriptions in library cards | +| `achievements_display` | AchievementsDisplay | Full | Full list / Summary only / Hidden | +| `confirm_before_delete` | bool | true | Show confirmation dialog before deleting a game | -## Theme Modes +### Imports + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `download_covers_auto` | bool | true | Automatically download cover art on import | -Three dark theme variants with progressively darker backgrounds: +### Shared Colony preferences -| Element | Dark | Darker | Midnight | -|---------|------|--------|----------| -| Main bg | `rgb(0.10, 0.10, 0.14)` | `rgb(0.06, 0.06, 0.09)` | `rgb(0.02, 0.02, 0.05)` | -| Sidebar bg | `rgb(0.08, 0.08, 0.12)` | `rgb(0.04, 0.04, 0.07)` | `rgb(0.01, 0.01, 0.03)` | -| Card bg | `rgb(0.12, 0.12, 0.16)` | `rgb(0.08, 0.08, 0.11)` | `rgb(0.04, 0.04, 0.07)` | -| Row bg | `rgb(0.09, 0.09, 0.13)` | `rgb(0.06, 0.06, 0.09)` | `rgb(0.03, 0.03, 0.05)` | +Theme, accent, text sizes and the accessibility switches are the ones every Colony program has (`colony_ui::preferences::StandardPreferences`), saved in the same file under the same names: -## Accent Colors +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `selected_theme`, `selected_variant` | string | `gruvbox`, `dark` | A shared Colony theme; an unknown one falls back to Gruvbox dark | +| `selected_accent` | string | none | Accent override (`red`, `orange`, `yellow`, `green`, `blue`, `indigo`, `violet`, `amber`); none uses the theme's own | +| `auto_accent` | bool | false | Use the theme's accent even when one is picked | +| `font_size` | small / default / large | default | Appearance text size | +| `animations` | bool | true | Animations and fades | +| `high_contrast` | bool | false | Push text and borders away from the background | +| `dyslexia_font` | bool | false | Use OpenDyslexic | +| `reduce_motion` | bool | false | Turn every animation off | +| `text_size_a11y` | small / default / large / xlarge | default | Accessibility text size, multiplied with `font_size` | -| Name | RGB | Usage | -|------|-----|-------| -| Blue | `rgb(0.6, 0.8, 1.0)` | Default. Sidebar title, active tabs, filter chips | -| Purple | `rgb(0.7, 0.5, 0.9)` | Alternative accent | -| Green | `rgb(0.4, 0.9, 0.5)` | Nature/organic theme | -| Gold | `rgb(1.0, 0.85, 0.3)` | Warm/premium theme | -| Red | `rgb(1.0, 0.45, 0.45)` | Bold/aggressive theme | -| Cyan | `rgb(0.3, 0.9, 0.9)` | Cool/tech theme | +## Upgrading from Spotter 0.3 -## High Contrast Mode +Spotter 0.3 and earlier kept the settings in the library database, with three dark themes, six accents and an interface scale of its own. The first start of a newer Spotter imports them into `preferences.json`: -When enabled, text and border colors shift toward higher luminance: +| Old setting | Becomes | +|-------------|---------| +| `theme_mode` Dark / Darker / Midnight | Tokyo Night night / Material deep ocean / Modus vivendi | +| `accent_color` Blue, Cyan / Purple / Green / Gold / Red | `blue` / `violet` / `green` / `amber` / `red` | +| `ui_scale` Small / Normal / Large | `text_size_a11y` small / default / large | -| Element | Normal | High Contrast | -|---------|--------|---------------| -| Border | `rgb(0.18, 0.18, 0.24)` | `rgb(0.35, 0.35, 0.45)` | -| Text Muted | `rgb(0.5, 0.5, 0.6)` | `rgb(0.6, 0.6, 0.7)` | -| Text Dim | `rgb(0.4, 0.4, 0.5)` | `rgb(0.5, 0.5, 0.6)` | -| Text Secondary | `rgb(0.6, 0.6, 0.7)` | `rgb(0.7, 0.7, 0.8)` | -| Text Light | `rgb(0.7, 0.7, 0.8)` | `rgb(1.0, 1.0, 1.0)` | +The old scale enlarged the whole window; the text sizes now enlarge the text only, and the Appearance and Accessibility sizes multiply (0.7225x to 1.68x). ## Style Helpers +Spotter defines no colour of its own. Widgets read the active Colony palette (`colony_ui::theme::Palette`, for example `Palette::TEXT_MUTED()`), so a theme, accent or high-contrast change reaches every screen. Platforms and game statuses use the shared accent swatches (`theme::shared_accent`), the same on every theme; text on a filled button takes `colony_ui::contrast_on` of the fill. + Reusable style functions in `theme.rs`: | Function | Description | |----------|-------------| | `card_style(bg, border, radius)` | Rounded card container | -| `chip_style(active, text, accent, radius)` | Filter chip toggle | +| `chip_style(active, color, radius)` | Chip toggle: tinted with `color` when active, outlined otherwise | | `transparent_btn_style()` | Invisible button wrapper | -| `primary_btn_style(bg)` | Solid filled action button | -| `outline_btn_style(color, bg)` | Outlined button | -| `disabled_btn_style()` | Grayed-out disabled button | -| `loading_btn_style()` | Gold-tinted importing state | -| `accent_card_style(bg, accent)` | Card with colored left border | +| `primary_btn_style(bg)` | Solid filled action button, label in `contrast_on(bg)` | +| `outline_btn_style(color)` | Outlined button on any surface | +| `disabled_btn_style()` | Dimmed disabled button | +| `loading_btn_style()` | Warning-tinted state for imports and logins in progress | +| `danger_btn_style()` | Delete action | +| `accent_card_style(accent)` | Card with a border tinted by `accent` | | `dot_style(color, size)` | Small circular indicator | | `pill_style(bg, border)` | Rounded status badge | -## Settings Schema Versioning +## Languages + +The interface ships in English and French. Spotter's own strings are in `src/i18n/en.json` and `src/i18n/fr.json`; the Preferences skeleton and the theme and accent names come from `colony_ui::i18n`. Views never hold text: they call `i18n::t(key)`, `i18n::tf(key, vars)` to fill `{name}` placeholders, or `i18n::tn(key, count, vars)` for a string that depends on a count (`key_one` and `key_other`; French also uses the singular for zero). Numbers with decimals and percentages go through `i18n::decimal` and `i18n::percent`, which follow the French typography (`12,5 %`). + +English is canonical: name a new key after its English meaning, write the English first, then the French in both files. `cargo test` fails when the two files hold different keys, when a string is empty or the placeholders differ, when a key is missing or unused, or when one repeats a shared colony-ui label. Platform, program and theme names are never translated. Game statuses are stored in the database by their English name (`GameStatus` `Display`) and shown through `GameStatus::label`. + +## Preferences Schema Versioning -Settings include a `version` field (currently `1`) with `#[serde(default)]` on newer fields. This allows older settings JSON to deserialize without breaking, with new fields taking their defaults. +Preferences include a `version` field (currently `2`: the move to `preferences.json`), and `Preferences` is `#[serde(default)]`, so a missing field takes its default. -`db::load_settings` also reads the stored JSON one field at a time when the whole struct does not parse: a value this build does not accept (for example a theme added by a newer version) falls back to that field's default, and every other setting keeps its stored value. +`preferences::parse` also reads the file one field at a time when the whole struct does not parse: a value this build does not accept (for example one added by a newer version) falls back to that field's default, and every other preference keeps its stored value. -If the startup load fails anyway, Spotter shows an error and runs on defaults, and refuses every write of saved data for the rest of the session: settings, profile and game saves, deletions, exports and the auto-backup are all refused (`Spotter::spawn_write`), so the data on disk stays as it was. +If the startup load fails anyway, for example because `preferences.json` is not JSON at all, Spotter shows an error and runs on defaults, and refuses every write of saved data for the rest of the session: preferences, profile and game saves, deletions, exports and the auto-backup are all refused (`Spotter::spawn_write`), so the data on disk stays as it was. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 43ef826..26e5157 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -101,9 +101,9 @@ UserProfile { } ``` -### Settings +### Preferences -25 user-configurable preferences. See [CONFIGURATION.md](CONFIGURATION.md) for details. +The user's preferences, saved to `preferences.json`, not to the database. See [CONFIGURATION.md](CONFIGURATION.md) for details. ## Database Schema @@ -171,11 +171,11 @@ CREATE TABLE playtime_history ( Single-row table (id = 1) storing `UserProfile` fields as individual columns. -The credential columns (`steam_api_key`, `gog_token`, `gog_refresh_token`, `xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) stay empty: `keyring.rs` keeps those secrets in the OS keyring. They hold a value, unencrypted, only when no keyring is usable, and `keyring.rs` moves it to the keyring on a later start. +The credential columns (`steam_api_key`, `gog_token`, `gog_refresh_token`, `xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) stay empty: `keyring.rs` keeps those secrets in the OS keyring, or, when no keyring is usable, in `credentials/tokens.json` in the config folder. A value here comes from Spotter 0.3 or earlier, or from a save that could not write that file, and `keyring.rs` moves it out on the next start. ### settings -Single-row table (id = 1) storing JSON-serialized `Settings` struct. +Single-row table (id = 1) holding the settings Spotter 0.3 and earlier saved, as JSON. A newer Spotter reads it once, to import it into `preferences.json`, and leaves it as it was. ```sql CREATE TABLE settings ( @@ -186,21 +186,35 @@ CREATE TABLE settings ( ## Storage Locations -| OS | Path | -|----|------| -| Linux | `~/.config/Colony/Spotter/` | -| macOS | `~/Library/Application Support/Colony/Spotter/` | -| Windows | `%LOCALAPPDATA%\Colony\Spotter\` | +Under `Colony/Spotter/` in the platform's config, data and cache folders (`colony_ui::paths`): + +| | Linux | macOS | Windows | +|---|---|---|---| +| Config | `~/.config/Colony/Spotter/` | `~/Library/Application Support/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | +| Data | `~/.local/share/Colony/Spotter/` | `~/Library/Application Support/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | +| Cache | `~/.cache/Colony/Spotter/` | `~/Library/Caches/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\cache\` | **Subdirectories:** ``` -Colony/Spotter/ -├── data/ +/ +├── preferences/ +│ └── preferences.json # Preferences, with the shared Colony ones +└── credentials/ + └── tokens.json # Platform tokens, only when no OS keyring is usable + +/ +├── library/ │ └── spotter.db # SQLite database -├── cache/ -│ ├── covers/ # Game cover art (JPEG) -│ └── achievement_icons/ # Achievement icons (colored + grayscale) -└── exports/ - └── spotter_export.json # JSON backup file +├── exports/ +│ ├── spotter_export.json # JSON backup file +│ └── spotter_auto_backup.json +└── logs/ + └── import.log + +/ +├── covers/ # Game cover art (JPEG) +└── achievement_icons/ # Achievement icons (colored + grayscale) ``` + +Spotter 0.3 and earlier kept `data/spotter.db`, `exports/`, `logs/` and `cache/` directly in the config folder. `paths.rs` copies the database, exports and log once (`.migrated-from-0.3` in the data folder marks it done), leaves the old files in place, and keeps using them if the copy fails. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 46b6d6d..9c5c4e7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -41,9 +41,8 @@ Tests are in `tests/unit_tests.rs` and `tests/integration_tests.rs`. Each DB tes | Category | Count | What's tested | |----------|-------|---------------| | Playtime display | 3 | Format hours/minutes strings | -| Models | 20+ | Enum roundtrips, Display impls, color values, settings serialization | +| Models | 20+ | Enum roundtrips, Display impls, distinct platform and status colours, settings serialization | | Database | 8 | CRUD, batch save, achievements, playtime history, export/import, profile, settings | -| Theme | 2 | ViewTheme high contrast, ThemeMode background differences | | API client | 3 | URL encode/decode, roundtrip | | Error | 2 | Display format, String conversion | @@ -64,8 +63,11 @@ src/ ├── lib.rs # Module tree; public API for integration tests ├── app.rs # Core state machine (~1300 lines) ├── models.rs # All data structures (~800 lines) +├── i18n/ # en.json, fr.json and the t/tf/tn lookup ├── db.rs # SQLite operations -├── theme.rs # Colors and style helpers +├── paths.rs # File locations, copy from Spotter 0.3 +├── preferences.rs # preferences.json load/save/import +├── theme.rs # Icons and style helpers on the Colony palette ├── error.rs # SpotterError enum ├── api_client.rs # HTTP utilities ├── images.rs # Cover/icon download + caching @@ -81,7 +83,7 @@ src/ ├── library.rs # Game list view ├── detail.rs # Game detail view ├── stats.rs # Statistics dashboard - ├── settings.rs # Preferences view + ├── preferences.rs # Preferences page ├── profile.rs # Profile & data management ├── import.rs # Import flows ├── sidebar.rs # Navigation sidebar @@ -94,7 +96,7 @@ docs/ ├── ARCHITECTURE.md # Architecture overview ├── DATA_MODEL.md # Data model & DB schema ├── API_INTEGRATIONS.md # External API documentation -├── CONFIGURATION.md # Settings & theming +├── CONFIGURATION.md # Preferences, languages and theming └── DEVELOPMENT.md # This file ``` @@ -124,7 +126,9 @@ Always open a fresh connection per operation via `db::open()`. The connection us ### Styling -Use `ViewTheme::from_settings()` at the top of each view function. For style closures (button/container styles), use the static constants from `theme.rs`. +Take every colour from the active palette (`colony_ui::theme::Palette::*()`), in view code and in style closures alike, and the style helpers in `theme.rs`. Never write a colour literal: it would ignore the user's theme and high-contrast setting. + +Write text with `theme::ui_text(content, base)` rather than `text(..).size(N)`: it sizes the text with `colony_ui::sz(base)`, so both text-size preferences scale it, sets it in the interface font, so the dyslexia font reaches it, and keeps the Nerd Font glyphs inside it in the glyph font. A text input takes `.size(sz(N)).font(ui_font())`. ## Environment Variables diff --git a/src/api_client.rs b/src/api_client.rs index b84ae52..2a1bbed 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -79,7 +79,8 @@ impl RateLimiter { /// the maximum size, the old file is rotated to `import.log.1` (keeping /// at most one backup). pub struct ImportLogger { - log_path: PathBuf, + /// `None` when Spotter has no logs folder: the logger then writes nothing. + log_path: Option, backup_path: PathBuf, max_size: u64, file: Mutex>, @@ -91,12 +92,26 @@ impl ImportLogger { Self::with_max_size(dir, DEFAULT_MAX_LOG_SIZE) } + /// The logger every platform import writes to, in Spotter's logs folder + /// (see `paths::layout`). + pub fn for_imports() -> Self { + match crate::paths::layout() { + Ok(layout) => Self::new(&layout.logs), + Err(_) => Self { + log_path: None, + backup_path: PathBuf::new(), + max_size: DEFAULT_MAX_LOG_SIZE, + file: Mutex::new(None), + }, + } + } + /// Create a logger with a custom max file size for rotation. pub fn with_max_size(dir: &Path, max_size: u64) -> Self { let log_path = dir.join("import.log"); let backup_path = dir.join("import.log.1"); Self { - log_path, + log_path: Some(log_path), backup_path, max_size, file: Mutex::new(None), @@ -105,6 +120,9 @@ impl ImportLogger { /// Log a message with a timestamp and module tag. pub fn log(&self, module: &str, message: &str) { + let Some(log_path) = &self.log_path else { + return; + }; let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); let line = format!("{} [{}] {}\n", timestamp, module, message); @@ -116,12 +134,12 @@ impl ImportLogger { } }; - // Rotate if needed — check size and rename, then drop the old handle - if let Ok(meta) = std::fs::metadata(&self.log_path) { + // Rotate if needed: check size and rename, then drop the old handle + if let Ok(meta) = std::fs::metadata(log_path) { if meta.len() >= self.max_size { // Drop the old file handle before renaming *guard = None; - let _ = std::fs::rename(&self.log_path, &self.backup_path); + let _ = std::fs::rename(log_path, &self.backup_path); eprintln!( "[logger] Rotated import.log ({} bytes) → import.log.1", meta.len() @@ -136,7 +154,7 @@ impl ImportLogger { match std::fs::OpenOptions::new() .create(true) .append(true) - .open(&self.log_path) + .open(log_path) { Ok(f) => { *guard = Some(f); @@ -157,15 +175,6 @@ impl ImportLogger { } } -/// Return the path to the import log directory (same as app data dir). -pub fn log_dir() -> PathBuf { - let dir = crate::db::base_dir().join("logs"); - if let Err(e) = std::fs::create_dir_all(&dir) { - eprintln!("[logger] Failed to create log dir: {}", e); - } - dir -} - /// Create an HTTP agent suitable for API calls (15s read timeout). pub fn api_agent() -> ureq::Agent { ureq::Agent::config_builder() diff --git a/src/app.rs b/src/app.rs index 88b4a30..bba2922 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,16 @@ use std::collections::{BTreeSet, HashSet, VecDeque}; +use colony_ui::theme::Palette; use iced::{Element, Task, Theme}; use crate::db; -use crate::messages::{AuthMessage, SettingsMessage}; +use crate::i18n::{t, tf, tn}; +use crate::messages::{AuthMessage, PreferencesMessage}; use crate::models::*; use crate::views; /// Type alias to reduce complexity in DataLoaded / load_data signatures. -pub type LoadResult = (Vec, UserProfile, Vec<(String, u32)>, Settings); +pub type LoadResult = (Vec, UserProfile, Vec<(String, u32)>, Preferences); /// GOG import result: games + optionally refreshed (access_token, refresh_token). pub type GogImportResult = (Vec, Option<(String, String)>); @@ -33,7 +35,7 @@ pub enum Screen { Profile, Import, AddGame, - Settings, + Preferences, } #[derive(Debug, Clone)] @@ -100,8 +102,8 @@ pub enum Message { AddGamePlatformChanged(Platform), AddGameStatusChanged(GameStatus), SaveNewGame, - // Settings (delegated to handlers/settings.rs) - Settings(SettingsMessage), + // Preferences page (delegated to handlers/preferences.rs) + Preferences(PreferencesMessage), // Undo deletion UndoDelete, PermanentDelete(Vec), @@ -133,9 +135,9 @@ pub enum Message { NoOp, } -impl From for Message { - fn from(msg: SettingsMessage) -> Self { - Message::Settings(msg) +impl From for Message { + fn from(msg: PreferencesMessage) -> Self { + Message::Preferences(msg) } } @@ -156,7 +158,8 @@ pub struct Spotter { pub playtime_data: Vec<(String, u32)>, /// Platforms currently importing (e.g. "Steam", "GOG"). Multiple can run in parallel. pub importing: BTreeSet, - pub import_status: String, + /// The import banner's last line: `Err` when it reports a failure. + pub import_status: Result, pub error_message: Option, pub success_message: Option, pub data_loaded: bool, @@ -183,22 +186,24 @@ pub struct Spotter { pub new_game_genre: String, pub new_game_platform: Platform, pub new_game_status: GameStatus, - // Settings - pub settings: Settings, - pub settings_section: SettingsSection, + pub preferences: Preferences, + /// The Preferences page's selected category, by index. + pub preferences_category: usize, + /// Where closing the Preferences page goes back to. + pub screen_before_preferences: Screen, + /// Preferences sections the user opened; every section starts collapsed. + pub preference_sections: HashSet, // Undo delete (supports single + bulk) pub recently_deleted: Vec<(Game, i64)>, // Profile save feedback pub profile_just_saved: bool, - // Settings save feedback - pub settings_just_saved: bool, // First launch onboarding pub first_launch: bool, // Bulk operations pub bulk_mode: bool, pub bulk_selected: HashSet, // Import history log - pub import_history: VecDeque, + pub import_history: VecDeque>, // Cascade import queue (games appear one-by-one) pub import_queue: Vec, pub import_queue_total: usize, @@ -226,7 +231,7 @@ impl Default for Spotter { profile: UserProfile::default(), playtime_data: Vec::new(), importing: BTreeSet::new(), - import_status: String::new(), + import_status: Ok(String::new()), error_message: None, success_message: None, data_loaded: false, @@ -245,11 +250,12 @@ impl Default for Spotter { new_game_genre: String::new(), new_game_platform: Platform::Steam, new_game_status: GameStatus::Unplayed, - settings: Settings::default(), - settings_section: SettingsSection::General, + preferences: Preferences::default(), + preferences_category: 0, + screen_before_preferences: Screen::Library, + preference_sections: HashSet::new(), recently_deleted: Vec::new(), profile_just_saved: false, - settings_just_saved: false, first_launch: false, bulk_mode: false, bulk_selected: HashSet::new(), @@ -290,19 +296,19 @@ impl Spotter { pub(crate) fn refresh_cover_cache(&mut self) { self.cover_cache.clear(); for g in &self.games { - if crate::images::cover_path(&g.title).exists() { + if crate::images::cover_path(&g.title).is_some_and(|p| p.exists()) { self.cover_cache.insert(g.title.clone()); } } for g in &self.import_queue { - if crate::images::cover_path(&g.title).exists() { + if crate::images::cover_path(&g.title).is_some_and(|p| p.exists()) { self.cover_cache.insert(g.title.clone()); } } } pub(crate) fn show_success(&mut self, msg: impl Into) { - if !self.settings.notifications_enabled { + if !self.preferences.notifications_enabled { return; } self.success_message = Some(msg.into()); @@ -330,7 +336,7 @@ impl Spotter { self.ensure_filter_cache(); } if self.toast_id != old_toast_id { - let duration = self.settings.toast_duration.millis(); + let duration = self.preferences.toast_duration.millis(); Task::batch([task, Self::toast_dismiss_timer(self.toast_id, duration)]) } else { task @@ -380,8 +386,8 @@ impl Spotter { if let Screen::AddGame = &screen { self.new_game_title.clear(); self.new_game_genre.clear(); - self.new_game_platform = self.settings.default_platform; - self.new_game_status = self.settings.default_status; + self.new_game_platform = self.preferences.default_platform; + self.new_game_status = self.preferences.default_status; } self.screen = screen; } @@ -391,7 +397,7 @@ impl Spotter { } Message::OpenUrl(url) => { if let Err(e) = crate::api_client::open_browser(&url) { - self.show_error(format!("Failed to open URL: {}", e)); + self.show_error(tf("error_open_url", &[("error", &e)])); } } Message::ClearAllFilters => { @@ -403,24 +409,24 @@ impl Spotter { Message::FilterStatus(status) => { self.filter_status = status; self.invalidate_filter_cache(); - if self.settings.remember_filters { - self.settings.last_filter_status = status; - return self.persist_settings(); + if self.preferences.remember_filters { + self.preferences.last_filter_status = status; + return self.persist_preferences(); } } Message::FilterPlatform(platform) => { self.filter_platform = platform; self.invalidate_filter_cache(); - if self.settings.remember_filters { - self.settings.last_filter_platform = platform; - return self.persist_settings(); + if self.preferences.remember_filters { + self.preferences.last_filter_platform = platform; + return self.persist_preferences(); } } Message::SetSortOrder(order) => { self.sort_order = order; - self.settings.default_sort_order = order; + self.preferences.default_sort_order = order; self.invalidate_filter_cache(); - return self.persist_settings(); + return self.persist_preferences(); } Message::SetGameStatus(id, status) => { if let Some(idx) = self.find_game_index(id) { @@ -446,7 +452,7 @@ impl Spotter { } // Delete with confirmation Message::ConfirmDeleteGame(id) => { - if self.settings.confirm_before_delete { + if self.preferences.confirm_before_delete { self.confirm_delete = Some(id); } else { return self.process_message(Message::DeleteGame(id)); @@ -456,6 +462,8 @@ impl Spotter { if self.confirm_delete.is_some() || self.confirm_bulk_delete { self.confirm_delete = None; self.confirm_bulk_delete = false; + } else if self.screen == Screen::Preferences { + return self.handle_preferences(PreferencesMessage::Toggle); } else { self.screen = Screen::Library; } @@ -467,7 +475,7 @@ impl Spotter { self.recently_deleted.push((game, id)); self.screen = Screen::Library; self.invalidate_filter_cache(); - self.show_success("Game deleted — click Undo to restore"); + self.show_success(t("toast_game_deleted")); let ids = vec![id]; return delay_task(8000, move || Message::PermanentDelete(ids)); } @@ -480,11 +488,7 @@ impl Spotter { self.games.push(game); } self.invalidate_filter_cache(); - if count == 1 { - self.show_success("Game restored"); - } else { - self.show_success(format!("{} games restored", count)); - } + self.show_success(tn("toast_games_restored", count, &[])); return self.persist_games(); } } @@ -534,16 +538,16 @@ impl Spotter { Message::ProfileSaved(result) => match result { Ok(()) => { self.profile_just_saved = true; - self.show_success("Profile saved successfully!"); + self.show_success(t("toast_profile_saved")); return delay_task(3000, || Message::ProfileSaveShown); } Err(e) => { - self.show_error(format!("Profile save error: {}", e)); + self.show_error(tf("error_profile_save", &[("error", &e)])); } }, Message::ProfileAutoSaved(result) => { if let Err(e) = result { - self.show_error(format!("Profile save error: {}", e)); + self.show_error(tf("error_profile_save", &[("error", &e)])); } } // ── Auth handlers (delegated to handlers/auth.rs) ── @@ -581,7 +585,7 @@ impl Spotter { } Message::DataSaved(result) => { if let Err(e) = result { - self.show_error(format!("Save error: {}", e)); + self.show_error(tf("error_save", &[("error", &e)])); } } Message::GameIdsMapped(result) => { @@ -611,7 +615,7 @@ impl Spotter { } } Err(e) => { - self.show_error(format!("Save error: {}", e)); + self.show_error(tf("error_save", &[("error", &e)])); } } } @@ -625,10 +629,10 @@ impl Spotter { Message::ExportCsv => return self.handle_export_csv(), Message::ExportComplete(result) => match result { Ok(path) => { - self.show_success(format!("Exported to {}", path)); + self.show_success(tf("toast_exported", &[("path", &path)])); } Err(e) => { - self.show_error(format!("Export error: {}", e)); + self.show_error(tf("error_export", &[("error", &e)])); } }, Message::AchievementsLoaded(result) => { @@ -651,7 +655,7 @@ impl Spotter { Message::SaveNewGame => { let title = self.new_game_title.trim().to_string(); if title.is_empty() { - self.show_error("Game title is required"); + self.show_error(t("error_title_required")); return Task::none(); } let title_lower = title.to_lowercase(); @@ -660,7 +664,7 @@ impl Spotter { .iter() .any(|g| g.title.to_lowercase() == title_lower) { - self.show_error(format!("'{}' already exists in your library", title)); + self.show_error(tf("error_game_exists", &[("title", &title)])); return Task::none(); } let game = Game { @@ -688,26 +692,28 @@ impl Spotter { }; self.games.push(game); self.invalidate_filter_cache(); - self.show_success(format!("'{}' added to library", self.new_game_title.trim())); + self.show_success(tf( + "toast_game_added", + &[("title", &self.new_game_title.trim())], + )); self.new_game_title.clear(); self.new_game_genre.clear(); - self.new_game_platform = self.settings.default_platform; - self.new_game_status = self.settings.default_status; + self.new_game_platform = self.preferences.default_platform; + self.new_game_status = self.preferences.default_status; self.screen = Screen::Library; return self.persist_games(); } - // Settings (delegated to handlers/settings.rs) - Message::Settings(settings_msg) => { - return self.handle_settings(settings_msg); + Message::Preferences(msg) => { + return self.handle_preferences(msg); } Message::ToggleFavorite(id) => { - if let Some(pos) = self.settings.favorites.iter().position(|&fid| fid == id) { - self.settings.favorites.remove(pos); + if let Some(pos) = self.preferences.favorites.iter().position(|&fid| fid == id) { + self.preferences.favorites.remove(pos); } else { - self.settings.favorites.push(id); + self.preferences.favorites.push(id); } self.invalidate_filter_cache(); - return self.persist_settings(); + return self.persist_preferences(); } Message::ToggleBulkMode => { self.bulk_mode = !self.bulk_mode; @@ -734,11 +740,15 @@ impl Spotter { let count = ids.len(); self.bulk_mode = false; self.invalidate_filter_cache(); - self.show_success(format!("{} games updated to {}", count, status)); + self.show_success(tn( + "toast_games_status", + count, + &[("status", &status.label())], + )); return self.persist_games(); } Message::BulkDelete => { - if self.settings.confirm_before_delete && !self.confirm_bulk_delete { + if self.preferences.confirm_before_delete && !self.confirm_bulk_delete { self.confirm_bulk_delete = true; return Task::none(); } @@ -764,7 +774,7 @@ impl Spotter { self.bulk_mode = false; self.invalidate_filter_cache(); self.screen = Screen::Library; - self.show_success(format!("{} games deleted — click Undo to restore", count)); + self.show_success(tn("toast_games_deleted", count, &[])); return delay_task(8000, move || Message::PermanentDelete(delete_ids)); } Message::ConfirmBulkDelete => { @@ -815,20 +825,18 @@ impl Spotter { } pub fn view(&self) -> Element<'_, Message> { - use iced::widget::{button, column, container, row, stack, text}; - use iced::{Alignment, Background, Border, Color, Length, Padding}; + use crate::theme::ui_text; + use iced::widget::{button, column, container, row, stack}; + use iced::{Alignment, Length, Padding}; let sidebar = views::sidebar::view(self); // Loading indicator before data is ready let content: Element<'_, Message> = if !self.data_loaded { - let vt = crate::theme::ViewTheme::from_settings(&self.settings); container( column![ - text("Loading your library...") - .size(18) - .color(vt.text_light), - text("Please wait").size(12).color(vt.text_muted), + ui_text(t("loading_library"), 18).color(Palette::TEXT_PRIMARY()), + ui_text(t("loading_wait"), 12).color(Palette::TEXT_MUTED()), ] .spacing(8) .align_x(Alignment::Center), @@ -846,7 +854,7 @@ impl Spotter { Screen::Profile => views::profile::view(self), Screen::Import => views::import::view(self), Screen::AddGame => views::add_game::view(self), - Screen::Settings => views::settings::view(self), + Screen::Preferences => views::preferences::view(self), } }; @@ -867,233 +875,69 @@ impl Spotter { // Delete confirmation modal overlay if let Some(delete_id) = self.confirm_delete { - let game_title = self + let title = self .games .iter() .find(|g| g.id == Some(delete_id)) - .map(|g| g.title.clone()) - .unwrap_or_else(|| "this game".to_string()); - - let backdrop = button( - container(text("").size(1)) - .width(Length::Fill) - .height(Length::Fill), - ) - .on_press(Message::CancelDelete) - .width(Length::Fill) - .height(Length::Fill) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.5))), - ..button::Style::default() - }); - layers.push(backdrop.into()); - - let modal_bg = self.settings.theme_mode.bg_card(); - let modal_card = container( - column![ - text(format!("{} Delete Game", crate::theme::icons::DELETE)) - .size(20) - .font(crate::theme::FONT_BOLD) - .color(crate::theme::DANGER), - text(format!( - "Are you sure you want to delete \"{}\"?", - game_title - )) - .size(14) - .color(crate::theme::TEXT_LIGHT), - text("You'll have 8 seconds to undo after deletion.") - .size(12) - .color(crate::theme::TEXT_MUTED), - row![ - button(text("Delete").size(14).color(Color::WHITE)) - .on_press(Message::DeleteGame(delete_id)) - .padding([10, 24]) - .style(|_: &Theme, status| { - let hover = matches!( - status, - button::Status::Hovered | button::Status::Pressed - ); - let bg = if hover { - Color::from_rgb(0.7, 0.12, 0.12) - } else { - Color::from_rgb(0.6, 0.1, 0.1) - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, - border: Border { - radius: 8.0.into(), - color: Color::from_rgb(0.8, 0.2, 0.2), - width: 1.0, - }, - ..button::Style::default() - } - }), - button(text("Cancel").size(14).color(crate::theme::TEXT_SECONDARY)) - .on_press(Message::CancelDelete) - .padding([10, 24]) - .style(crate::theme::outline_btn_style( - crate::theme::TEXT_SECONDARY, - modal_bg, - )), - ] - .spacing(12), - ] - .spacing(12), - ) - .padding(24) - .max_width(450) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(modal_bg)), - border: Border { - radius: 12.0.into(), - color: Color::from_rgb(0.6, 0.15, 0.15), - width: 2.0, - }, - ..container::Style::default() - }); - - let modal_positioned = container(modal_card) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill); - layers.push(modal_positioned.into()); + .map_or("", |g| g.title.as_str()); + layers.push(views::helpers::delete_modal( + t("delete_game_title"), + tf("delete_game_question", &[("title", &title)]), + t("action_delete").to_owned(), + Message::DeleteGame(delete_id), + )); } else if self.confirm_bulk_delete { let count = self.bulk_selected.len(); - - let backdrop = button( - container(text("").size(1)) - .width(Length::Fill) - .height(Length::Fill), - ) - .on_press(Message::CancelDelete) - .width(Length::Fill) - .height(Length::Fill) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.5))), - ..button::Style::default() - }); - layers.push(backdrop.into()); - - let modal_bg = self.settings.theme_mode.bg_card(); - let modal_card = container( - column![ - text(format!("{} Bulk Delete", crate::theme::icons::DELETE)) - .size(20) - .font(crate::theme::FONT_BOLD) - .color(crate::theme::DANGER), - text(format!("Are you sure you want to delete {} games?", count)) - .size(14) - .color(crate::theme::TEXT_LIGHT), - text("You'll have 8 seconds to undo after deletion.") - .size(12) - .color(crate::theme::TEXT_MUTED), - row![ - button( - text(format!("Delete {}", count)) - .size(14) - .color(Color::WHITE) - ) - .on_press(Message::ConfirmBulkDelete) - .padding([10, 24]) - .style(|_: &Theme, status| { - let hover = - matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if hover { - Color::from_rgb(0.7, 0.12, 0.12) - } else { - Color::from_rgb(0.6, 0.1, 0.1) - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: Color::WHITE, - border: Border { - radius: 8.0.into(), - color: Color::from_rgb(0.8, 0.2, 0.2), - width: 1.0, - }, - ..button::Style::default() - } - }), - button(text("Cancel").size(14).color(crate::theme::TEXT_SECONDARY)) - .on_press(Message::CancelDelete) - .padding([10, 24]) - .style(crate::theme::outline_btn_style( - crate::theme::TEXT_SECONDARY, - modal_bg, - )), - ] - .spacing(12), - ] - .spacing(12), - ) - .padding(24) - .max_width(450) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(modal_bg)), - border: Border { - radius: 12.0.into(), - color: Color::from_rgb(0.6, 0.15, 0.15), - width: 2.0, - }, - ..container::Style::default() - }); - - let modal_positioned = container(modal_card) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill); - layers.push(modal_positioned.into()); + layers.push(views::helpers::delete_modal( + t("delete_games_title"), + tn("delete_games_question", count, &[]), + tf("action_delete_count", &[("count", &count)]), + Message::ConfirmBulkDelete, + )); } // Toast notification if let Some(msg) = toast_msg { let is_error = self.error_message.is_some() && self.success_message.is_none(); let accent = if is_error { - crate::theme::DANGER + Palette::ERROR() } else { - crate::theme::SUCCESS + Palette::SUCCESS() }; let has_undo = !self.recently_deleted.is_empty(); - let toast_bg = self.settings.theme_mode.bg_card(); - let toast_content: Element<'_, Message> = if has_undo && !is_error { - let undo_btn = button(text("Undo").size(13).color(crate::theme::ACCENT_BLUE)) - .on_press(Message::UndoDelete) - .padding([4, 12]) - .style(crate::theme::outline_btn_style( - crate::theme::ACCENT_BLUE, - toast_bg, - )); - row![text(msg.clone()).size(13).color(accent), undo_btn,] - .spacing(12) - .align_y(iced::Alignment::Center) - .into() - } else { - text(msg.clone()).size(13).color(accent).into() - }; + let mut toast_content = row![ui_text(msg.as_str(), 13).color(accent)] + .spacing(12) + .align_y(iced::Alignment::Center); + if has_undo && !is_error { + toast_content = toast_content.push( + button(ui_text(t("action_undo"), 13)) + .on_press(Message::UndoDelete) + .padding([4, 12]) + .style(crate::theme::outline_btn_style(Palette::ACCENT())), + ); + } + toast_content = toast_content + .push(ui_text(colony_ui::widgets::icons::CLOSE, 12).color(Palette::TEXT_DIMMER())); - let toast = container(toast_content) + // A click anywhere on the toast dismisses it, as on every Colony + // toast; Undo is a button of its own inside it. + let toast = button(toast_content) + .on_press(Message::ToastTick(self.toast_id)) .padding([10, 16]) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(toast_bg)), - border: Border { + .style(move |_: &Theme, _| button::Style { + background: Some(Palette::BG_CARD().into()), + text_color: Palette::TEXT_PRIMARY(), + border: iced::Border { radius: 10.0.into(), - color: Color { - r: accent.r * 0.4, - g: accent.g * 0.4, - b: accent.b * 0.4, - a: 1.0, - }, + color: accent.scale_alpha(0.4), width: 1.0, }, - ..container::Style::default() + ..button::Style::default() }); - let sidebar_w = self.settings.sidebar_width.clamp(160, 400) as f32 + 20.0; + let sidebar_w = self.preferences.sidebar_width.clamp(160, 400) as f32 + 20.0; let toast_positioned = container(toast) .width(Length::Fill) .height(Length::Fill) @@ -1110,12 +954,20 @@ impl Spotter { stack(layers).into() } + /// iced's own widgets (text inputs, pickers, scrollbars) in the colours + /// of the active Colony theme. pub fn theme(&self) -> Theme { - match self.settings.theme_mode { - ThemeMode::Dark => Theme::Dark, - ThemeMode::Darker => Theme::Moonfly, - ThemeMode::Midnight => Theme::Oxocarbon, - } + Theme::custom( + "Colony", + iced::theme::Palette { + background: Palette::BG_PRIMARY(), + text: Palette::TEXT_PRIMARY(), + primary: Palette::ACCENT(), + success: Palette::SUCCESS(), + warning: Palette::WARNING(), + danger: Palette::ERROR(), + }, + ) } pub fn filtered_games(&self) -> Vec<(usize, &Game)> { @@ -1164,8 +1016,8 @@ impl Spotter { } } - if self.sort_order == SortOrder::FavoritesFirst && !self.settings.favorites.is_empty() { - let favorites = &self.settings.favorites; + if self.sort_order == SortOrder::FavoritesFirst && !self.preferences.favorites.is_empty() { + let favorites = &self.preferences.favorites; games.sort_by(|a, b| { let a_fav = a.1.id.is_some_and(|id| favorites.contains(&id)); let b_fav = b.1.id.is_some_and(|id| favorites.contains(&id)); @@ -1249,24 +1101,28 @@ where } fn load_data() -> Result { + let layout = crate::paths::layout()?; let conn = db::open()?; let games = db::load_games(&conn)?; crate::keyring::init(); - let profile = crate::keyring::load_profile(&conn)?; + let profile = crate::keyring::load_profile(&conn, &layout.credentials)?; let playtime = db::get_daily_playtime(&conn, 30)?; - let settings = db::load_settings(&conn)?; - Ok((games, profile, playtime, settings)) + let prefs = crate::preferences::load(&layout.preferences, &conn)?; + Ok((games, profile, playtime, prefs)) } pub fn run() -> iced::Result { - iced::application(boot, Spotter::update, Spotter::view) - .title("Spotter — Game Tracker") + // The Colony fonts (and their licences) ship inside colony-ui. + colony_ui::fonts::BYTES + .into_iter() + .fold( + iced::application(boot, Spotter::update, Spotter::view), + |app, font| app.font(font), + ) + .title("Spotter") .theme(Spotter::theme) .subscription(Spotter::subscription) - .font(include_bytes!("../assets/fonts/JetBrainsMonoNerdFont-Regular.ttf").as_slice()) - .font(include_bytes!("../assets/fonts/JetBrainsMonoNerdFont-Bold.ttf").as_slice()) - .default_font(iced::Font::with_name("JetBrainsMono NF")) - .scale_factor(|app: &Spotter| app.settings.ui_scale.factor()) + .default_font(colony_ui::ui_font()) .window(iced::window::Settings { icon: window_icon(), ..Default::default() @@ -1281,8 +1137,22 @@ fn window_icon() -> Option { } fn boot() -> (Spotter, Task) { + // The saved theme, sizes and language from the first frame on. The full load reads + // the preferences again, and imports an older Spotter's if there are none. + let prefs = crate::paths::layout() + .ok() + .and_then(|layout| std::fs::read_to_string(&layout.preferences).ok()) + .and_then(|json| crate::preferences::parse(&json).ok()) + .unwrap_or_default(); + prefs.apply(); let load_task = spawn_task(load_data, |r| Message::DataLoaded(Box::new(r))); - (Spotter::default(), load_task) + ( + Spotter { + preferences: prefs, + ..Spotter::default() + }, + load_task, + ) } #[cfg(test)] diff --git a/src/db.rs b/src/db.rs index ab62078..bb8d893 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,5 +1,4 @@ use rusqlite::{params, Connection}; -use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use crate::error::SpotterError; @@ -8,69 +7,10 @@ use crate::models::*; /// Track whether migrations have run this session (avoids 37 SQL statements per open). static MIGRATED: AtomicBool = AtomicBool::new(false); -/// Raw base path without directory creation. -/// Linux: ~/.config/Colony/Spotter/ -/// Windows: AppData/Local/Colony/Spotter/ -/// macOS: ~/Library/Application Support/Colony/Spotter/ -fn base_path() -> PathBuf { - #[cfg(target_os = "windows")] - let base = dirs::data_local_dir(); - #[cfg(not(target_os = "windows"))] - let base = dirs::config_dir(); - - base.unwrap_or_else(|| PathBuf::from(".")) - .join("Colony") - .join("Spotter") -} - -/// Ensure a directory exists, logging on failure. -fn ensure_dir(dir: &std::path::Path) { - if let Err(e) = std::fs::create_dir_all(dir) { - eprintln!("[db] Failed to create directory {:?}: {}", dir, e); - } -} - -/// Base directory for all Spotter data. -pub fn base_dir() -> PathBuf { - let dir = base_path(); - ensure_dir(&dir); - dir -} - -/// Data directory (database). -pub fn data_dir() -> PathBuf { - let dir = base_path().join("data"); - ensure_dir(&dir); - dir -} - -/// Cache directory (covers, achievement icons). -pub fn cache_dir() -> PathBuf { - let dir = base_path().join("cache"); - ensure_dir(&dir); - dir -} - -/// Covers cache directory. -pub fn covers_dir() -> PathBuf { - let dir = base_path().join("cache").join("covers"); - ensure_dir(&dir); - dir -} - -/// Exports directory (JSON/CSV backups). -pub fn exports_dir() -> PathBuf { - let dir = base_path().join("exports"); - ensure_dir(&dir); - dir -} - -pub fn db_path() -> PathBuf { - data_dir().join("spotter.db") -} - +/// Open the library database, where `paths::layout` puts it. pub fn open() -> Result { - open_inner(db_path().as_ref(), true) + let layout = crate::paths::layout().map_err(SpotterError::Other)?; + open_inner(&layout.db, true) } /// Open (or create) a database at an explicit path. @@ -186,6 +126,8 @@ fn init_tables(conn: &Connection) -> Result<(), SpotterError> { UNIQUE(steam_appid, platform_id, api_name) ); + -- Settings saved by Spotter 0.3 and earlier, read once to import them + -- into preferences.json. CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL DEFAULT '{}' @@ -207,7 +149,7 @@ fn init_tables(conn: &Connection) -> Result<(), SpotterError> { } /// Run migrations for existing databases (add columns that might be missing). -/// ALTER TABLE ADD COLUMN returns an error if the column already exists — that's expected +/// ALTER TABLE ADD COLUMN returns an error if the column already exists: that's expected /// and we intentionally ignore it. Real errors (disk full, corruption) are logged. fn migrate(conn: &Connection) { let migrations = [ @@ -239,7 +181,7 @@ fn migrate(conn: &Connection) { // "duplicate column name" is expected for already-applied migrations. // Also check for "duplicate column" to handle different SQLite versions. if !msg.contains("duplicate column") && !msg.contains("already exists") { - eprintln!("[db] Migration warning: {} — {}", sql, msg); + eprintln!("[db] Migration warning: {}: {}", sql, msg); } } } @@ -602,7 +544,10 @@ pub fn export_games_csv_from_slice(games: &[Game]) -> Result Result, SpotterError> { - let import_path = exports_dir().join("spotter_export.json"); + let import_path = crate::paths::layout() + .map_err(SpotterError::Other)? + .exports + .join("spotter_export.json"); if !import_path.exists() { return Err(SpotterError::Import(format!( "No backup file found at {}. Export first, then import.", @@ -652,51 +597,16 @@ pub fn save_achievements( Ok(()) } -/// Load application settings (returns defaults if not yet saved). -pub fn load_settings(conn: &Connection) -> Result { - let mut stmt = conn.prepare("SELECT data FROM settings WHERE id = 1")?; - - let result = stmt.query_row([], |row| row.get::<_, String>(0)); - - match result { - Ok(json) => parse_settings(&json), - Err(_) => Ok(Settings::default()), - } -} - -/// Parse stored settings one field at a time: a field whose value this build -/// does not accept (for example a theme added by a newer version) keeps its -/// default, so one bad value never discards the other settings. -fn parse_settings(json: &str) -> Result { - use serde::Deserialize; - - if let Ok(settings) = serde_json::from_str(json) { - return Ok(settings); - } - let stored: serde_json::Map = serde_json::from_str(json)?; - let mut merged = serde_json::to_value(Settings::default())?; - for (key, value) in stored { - let Some(slot) = merged.get_mut(&key) else { - continue; - }; - let previous = std::mem::replace(slot, value); - if Settings::deserialize(&merged).is_err() { - eprintln!("[db] Ignoring invalid stored setting {:?}", key); - merged[&key] = previous; - } - } - Ok(Settings::deserialize(&merged)?) -} - -/// Save application settings as JSON. -pub fn save_settings(conn: &Connection, settings: &Settings) -> Result<(), SpotterError> { - let json = serde_json::to_string(settings)?; - conn.execute( - "INSERT INTO settings (id, data) VALUES (1, ?1) - ON CONFLICT(id) DO UPDATE SET data=?1", - params![json], - )?; - Ok(()) +/// The settings Spotter 0.3 and earlier saved in the database, as JSON, if +/// any. Spotter now keeps them in `preferences.json` and only reads this row +/// once, to import it (see `crate::preferences`); the row is left as it was. +pub fn load_legacy_settings(conn: &Connection) -> Result, SpotterError> { + use rusqlite::OptionalExtension; + Ok(conn + .query_row("SELECT data FROM settings WHERE id = 1", [], |row| { + row.get(0) + }) + .optional()?) } /// Load all achievements for a given Steam appid, in insertion order. diff --git a/src/epic/api.rs b/src/epic/api.rs index 58f0720..73a57ff 100644 --- a/src/epic/api.rs +++ b/src/epic/api.rs @@ -86,7 +86,7 @@ pub(super) fn fetch_entitlements( Ok(all_entitlements) } -/// Fetch launcher assets — the primary endpoint the EGS Launcher uses. +/// Fetch launcher assets: the primary endpoint the EGS Launcher uses. /// This returns all installable games for the user on a given platform. /// Free claimed games typically appear here even if absent from the library service. /// Non-fatal: returns empty vec on error rather than failing the whole import. diff --git a/src/epic/auth.rs b/src/epic/auth.rs index ea3a251..16a46cf 100644 --- a/src/epic/auth.rs +++ b/src/epic/auth.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -/// Epic Games OAuth credentials — well-known launcher client used by Legendary/Heroic. +/// Epic Games OAuth credentials: well-known launcher client used by Legendary/Heroic. /// These are the same credentials every open-source Epic client uses. const EPIC_CLIENT_ID: &str = "34a02cf8f4414e29b15921876da36f9a"; const EPIC_CLIENT_SECRET: &str = "daafbccc737745039dffe53d94fc76cf"; @@ -128,7 +128,7 @@ pub fn exchange_code(code: &str) -> Result { /// Returns (access_token, refresh_token, account_id, display_name). pub fn refresh_token(refresh: &str) -> Result { if refresh.is_empty() { - return Err("No refresh token available. Please login again.".into()); + return Err(crate::i18n::t("error_login_expired").into()); } let url = format!("{}/account/api/oauth/token", EPIC_OAUTH_HOST); diff --git a/src/epic/filters.rs b/src/epic/filters.rs index 4ead9f2..d2908ff 100644 --- a/src/epic/filters.rs +++ b/src/epic/filters.rs @@ -11,7 +11,7 @@ pub(super) fn is_non_game_app_name(app_name: &str) -> bool { let upper = app_name.to_uppercase(); // Hex-only strings (32+ chars) are catalog IDs, not real app names if app_name.len() >= 32 && app_name.chars().all(|c| c.is_ascii_hexdigit()) { - return false; // Don't skip — might resolve to a game via catalog + return false; // Don't skip: might resolve to a game via catalog } upper.contains("PROMO") || upper.contains("COUPON") diff --git a/src/epic/graphql.rs b/src/epic/graphql.rs index 119dcec..6f61ca4 100644 --- a/src/epic/graphql.rs +++ b/src/epic/graphql.rs @@ -124,7 +124,7 @@ pub(super) fn graphql_post( } /// Fetch community rating for a game via GraphQL RatingsPolls API. -/// Returns a review percentage (0–100) if available. +/// Returns a review percentage (0-100) if available. pub(super) fn fetch_graphql_rating(agent: &ureq::Agent, namespace: &str) -> Option { let graphql_url = "https://store.epicgames.com/graphql"; let query = serde_json::json!({ @@ -204,7 +204,7 @@ pub(super) fn fetch_achievement_definitions(agent: &ureq::Agent, namespace: &str /// Fetch achievement total for a game. /// Returns (0, achievements_total). /// -/// NOTE: Player-specific unlock counts are unavailable — Epic's launcher OAuth +/// NOTE: Player-specific unlock counts are unavailable: Epic's launcher OAuth /// client (launcherAppClient2) lacks the "achievements" scope, so all known /// GraphQL endpoints reject the Bearer token for playerAchievementGameRecords. /// Heroic and Legendary have the same limitation. We only fetch the public diff --git a/src/epic/local.rs b/src/epic/local.rs index 46ef199..b4c5a40 100644 --- a/src/epic/local.rs +++ b/src/epic/local.rs @@ -138,7 +138,7 @@ pub fn full_import_local() -> Result, String> { ); } - let logger = crate::api_client::ImportLogger::new(&crate::api_client::log_dir()); + let logger = crate::api_client::ImportLogger::for_imports(); logger.log("epic", "Starting Epic Games local import"); eprintln!("[epic] Found {} local games", manifests.len()); diff --git a/src/epic/online.rs b/src/epic/online.rs index 99c386d..6d0d168 100644 --- a/src/epic/online.rs +++ b/src/epic/online.rs @@ -15,7 +15,7 @@ pub fn full_import_online( account_id: &str, refresh_token: &str, ) -> Result<(Vec, Option), String> { - let logger = crate::api_client::ImportLogger::new(&crate::api_client::log_dir()); + let logger = crate::api_client::ImportLogger::for_imports(); logger.log("epic", "Starting Epic Games online import"); // Try with current token first; if it fails with 401, refresh and retry @@ -74,7 +74,7 @@ fn import_with_token( ); } - // Step 2b: Fetch launcher assets — this is the primary source used by the + // Step 2b: Fetch launcher assets: this is the primary source used by the // actual EGS Launcher to list installable games. Free claimed games often // appear here even when absent from the library service. let launcher_assets = fetch_launcher_assets(&agent, token); @@ -494,7 +494,7 @@ fn import_with_token( } } - // (d) GraphQL searchStore fallback — when all store-content lookups 404'd + // (d) GraphQL searchStore fallback: when all store-content lookups 404'd let still_needs = pg.game.cover_url.is_empty() && pg.game.description.is_empty(); if still_needs && !pg.namespace.is_empty() { eprintln!( diff --git a/src/epic/types.rs b/src/epic/types.rs index 59a1ba0..f9c063a 100644 --- a/src/epic/types.rs +++ b/src/epic/types.rs @@ -13,7 +13,7 @@ pub(super) struct LibraryRecord { /// Can sometimes be used as a store slug for metadata lookup. #[serde(alias = "productId", default)] pub product_id: String, - /// Sandbox name (e.g. "Live", "UE Marketplace") — useful for filtering. + /// Sandbox name (e.g. "Live", "UE Marketplace"): useful for filtering. #[serde(alias = "sandboxName", default)] #[allow(dead_code)] pub sandbox_name: String, diff --git a/src/gog/auth.rs b/src/gog/auth.rs index a51c3cd..2177fb7 100644 --- a/src/gog/auth.rs +++ b/src/gog/auth.rs @@ -82,7 +82,7 @@ pub fn exchange_code(code: &str) -> Result<(String, String), String> { /// Returns (new_access_token, new_refresh_token). pub fn refresh_token(refresh: &str) -> Result<(String, String), String> { if refresh.is_empty() { - return Err("No refresh token available. Please login again.".into()); + return Err(crate::i18n::t("error_login_expired").into()); } let response = post_token(&[("grant_type", "refresh_token"), ("refresh_token", refresh)]) diff --git a/src/gog/mod.rs b/src/gog/mod.rs index 1933b76..694d252 100644 --- a/src/gog/mod.rs +++ b/src/gog/mod.rs @@ -71,7 +71,7 @@ pub fn full_import(token: &str) -> Result, String> { return Err("GOG token is required.".into()); } - let logger = api_client::ImportLogger::new(&api_client::log_dir()); + let logger = api_client::ImportLogger::for_imports(); logger.log("gog", "Starting GOG import"); // Step 1: Get all owned games from the filtered listing (games only, no DLCs). diff --git a/src/handlers/data.rs b/src/handlers/data.rs index ff5d5ab..7c4f72c 100644 --- a/src/handlers/data.rs +++ b/src/handlers/data.rs @@ -8,10 +8,11 @@ use iced::Task; use crate::app::{Message, Spotter}; use crate::db; +use crate::i18n::{t, tf, tn}; use crate::models::*; -/// Error every write reports after a failed startup load. -const NOT_LOADED: &str = "your saved data was not loaded, so changes are not saved"; +/// The import log keeps this many entries. +const IMPORT_LOG_SIZE: usize = 50; impl Spotter { // ── Data loading ── @@ -22,7 +23,7 @@ impl Spotter { ) -> Task { self.data_loaded = true; match *result { - Ok((games, profile, playtime, settings)) => { + Ok((games, profile, playtime, prefs)) => { if !games.is_empty() { self.games = games; } else { @@ -30,24 +31,25 @@ impl Spotter { } self.profile = profile; self.playtime_data = playtime; - self.settings = settings; + self.preferences = prefs; + self.preferences.apply(); // Apply start screen setting - self.screen = match self.settings.start_screen { + self.screen = match self.preferences.start_screen { StartScreen::Statistics => crate::app::Screen::Statistics, StartScreen::Import => crate::app::Screen::Import, StartScreen::Profile => crate::app::Screen::Profile, StartScreen::Library => crate::app::Screen::Library, }; // Restore sort order from settings - self.sort_order = self.settings.default_sort_order; + self.sort_order = self.preferences.default_sort_order; // Restore filters if remember_filters is enabled - if self.settings.remember_filters { - self.filter_status = self.settings.last_filter_status; - self.filter_platform = self.settings.last_filter_platform; + if self.preferences.remember_filters { + self.filter_status = self.preferences.last_filter_status; + self.filter_platform = self.preferences.last_filter_platform; } // Apply default platform/status from settings for add-game form - self.new_game_platform = self.settings.default_platform; - self.new_game_status = self.settings.default_status; + self.new_game_platform = self.preferences.default_platform; + self.new_game_status = self.preferences.default_status; // Build caches once at startup self.refresh_cover_cache(); self.invalidate_filter_cache(); @@ -81,10 +83,7 @@ impl Spotter { } Err(e) => { self.load_failed = true; - self.show_error(format!( - "Your saved data was not loaded ({}). Nothing will be saved until Spotter restarts and loads it.", - e - )); + self.show_error(tf("error_load_failed", &[("error", &e)])); } } Task::none() @@ -96,15 +95,15 @@ impl Spotter { match db::export_games_json_from_slice(&self.games) { Ok(json) => self.spawn_write( move || { - let export_path = db::exports_dir().join("spotter_export.json"); + let export_path = crate::paths::layout()?.exports.join("spotter_export.json"); std::fs::write(&export_path, &json) - .map_err(|e| format!("Write error: {}", e))?; + .map_err(|e| tf("error_write", &[("error", &e)]))?; Ok(export_path.to_string_lossy().to_string()) }, Message::ExportComplete, ), Err(e) => { - self.show_error(format!("Export error: {}", e)); + self.show_error(tf("error_export", &[("error", &e)])); Task::none() } } @@ -114,15 +113,15 @@ impl Spotter { match db::export_games_csv_from_slice(&self.games) { Ok(csv) => self.spawn_write( move || { - let export_path = db::exports_dir().join("spotter_export.csv"); + let export_path = crate::paths::layout()?.exports.join("spotter_export.csv"); std::fs::write(&export_path, &csv) - .map_err(|e| format!("Write error: {}", e))?; + .map_err(|e| tf("error_write", &[("error", &e)]))?; Ok(export_path.to_string_lossy().to_string()) }, Message::ExportComplete, ), Err(e) => { - self.show_error(format!("Export error: {}", e)); + self.show_error(tf("error_export", &[("error", &e)])); Task::none() } } @@ -222,14 +221,15 @@ impl Spotter { eprintln!("[app] {} import returned {} games", platform, count); let (added, updated) = self.merge_imported_games(new_games); self.invalidate_filter_cache(); - let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); - if self.import_history.len() >= 50 { - self.import_history.pop_front(); - } - self.import_history.push_back(format!( - "[{}] {}: {} found, {} new, {} updated", - timestamp, platform, count, added, updated - )); + self.log_import(Ok(tf( + "import_log_done", + &[ + ("platform", &platform), + ("found", &count), + ("added", &added), + ("updated", &updated), + ], + ))); // Persist ALL games (including new ones) to DB immediately, // then move new games to the cascade queue for visual effect. @@ -241,9 +241,11 @@ impl Spotter { let backup_task = match db::export_games_json_from_slice(&self.games) { Ok(json) => self.spawn_write( move || { - let path = db::exports_dir().join("spotter_auto_backup.json"); + let path = crate::paths::layout()? + .exports + .join("spotter_auto_backup.json"); std::fs::write(&path, &json) - .map_err(|e| format!("Auto-backup write error: {}", e))?; + .map_err(|e| tf("error_backup", &[("error", &e)]))?; eprintln!("[app] Auto-backup saved to {:?}", path); Ok(()) }, @@ -263,15 +265,30 @@ impl Spotter { self.import_queue[queue_start..].reverse(); self.import_queue_total += added; - self.import_status = format!("{}: importing {} games...", platform, added); + self.import_status = Ok(tn("import_adding", added, &[("platform", &platform)])); - let drain = Self::cascade_delay(30); + // The cascade is an animation: with effects off or reduced + // motion on, every new game lands at once. + let drain = if colony_ui::effects_enabled() { + Self::cascade_delay(30) + } else { + self.handle_drain_import_queue() + }; Task::batch([save, drain, backup_task]) } else { - self.import_status = - format!("{}: {} found, {} updated", platform, count, updated); - self.show_success(format!("{}: {} updated", platform, updated)); - let covers = if self.settings.download_covers_auto { + self.import_status = Ok(tf( + "import_updated", + &[ + ("platform", &platform), + ("found", &count), + ("updated", &updated), + ], + )); + self.show_success(tf( + "toast_import_updated", + &[("platform", &platform), ("updated", &updated)], + )); + let covers = if self.preferences.download_covers_auto { Task::perform(async {}, |_| Message::DownloadCovers) } else { Task::none() @@ -280,19 +297,26 @@ impl Spotter { } } Err(e) => { - self.import_status = format!("{} error: {}", platform, e); - let timestamp = chrono::Local::now().format("%H:%M:%S").to_string(); - if self.import_history.len() >= 50 { - self.import_history.pop_front(); - } - self.import_history - .push_back(format!("[{}] {} ERROR: {}", timestamp, platform, e)); + let failure = tf("import_failed", &[("platform", &platform), ("error", &e)]); + self.import_status = Err(failure.clone()); + self.log_import(Err(failure)); self.show_error(e); Task::none() } } } + /// Add a line, stamped with the time, to the import log. + fn log_import(&mut self, entry: Result) { + let time = chrono::Local::now().format("%H:%M:%S"); + if self.import_history.len() >= IMPORT_LOG_SIZE { + self.import_history.pop_front(); + } + let stamp = |text: String| format!("[{time}] {text}"); + self.import_history + .push_back(entry.map(stamp).map_err(stamp)); + } + // ── Cascade import ── pub(crate) fn handle_drain_import_queue(&mut self) -> Task { @@ -304,7 +328,9 @@ impl Spotter { // Adaptive batch size: larger batches for big queues so 1 600-game imports // finish in seconds instead of minutes. - let batch = if remaining_before > 500 { + let batch = if !colony_ui::effects_enabled() { + remaining_before + } else if remaining_before > 500 { 50 } else if remaining_before > 200 { 25 @@ -331,7 +357,10 @@ impl Spotter { let done = self.import_queue_total - remaining; if remaining > 0 { - self.import_status = format!("Importing... {}/{}", done, self.import_queue_total); + self.import_status = Ok(tf( + "import_progress", + &[("done", &done), ("total", &self.import_queue_total)], + )); // Adaptive delay: slow down when few remain for a nicer visual finish. let delay = if remaining > 200 { 30 @@ -345,13 +374,13 @@ impl Spotter { return Self::cascade_delay(delay); } else { // Cascade complete - self.import_status = - format!("Import complete: {} games added", self.import_queue_total); - self.show_success(format!("{} games imported!", self.import_queue_total)); + let total = self.import_queue_total; + self.import_status = Ok(tn("import_complete", total, &[])); + self.show_success(tn("toast_games_imported", total, &[])); self.import_queue_total = 0; // Rebuild cover cache: re-imported games may already have covers on disk. self.refresh_cover_cache(); - if self.settings.download_covers_auto { + if self.preferences.download_covers_auto { return Task::perform(async {}, |_| Message::DownloadCovers); } } @@ -431,7 +460,7 @@ impl Spotter { let mut updated = 0; for game in new_games { - // Lookup by platform ID — HashMap::get accepts &str via Borrow + // Lookup by platform ID: HashMap::get accepts &str via Borrow let idx_opt = game .steam_appid .and_then(|id| steam_idx.get(&id)) @@ -523,47 +552,49 @@ impl Spotter { F: FnOnce() -> Result + Send + 'static, { if self.load_failed { - return Task::done(msg(Err(NOT_LOADED.into()))); + return Task::done(msg(Err(t("error_not_loaded").into()))); } crate::app::spawn_task(f, msg) } - /// Save profile to database and OS keyring in a background thread. + /// Save the profile to the database and its tokens to the OS keyring, in a + /// background thread. pub(crate) fn save_profile_task(&self) -> Task { let profile = self.profile.clone(); self.spawn_write( move || { let conn = db::open()?; - crate::keyring::save_profile(&conn, &profile)?; + let file = &crate::paths::layout()?.credentials; + crate::keyring::save_profile(&conn, file, &profile)?; Ok(()) }, Message::ProfileSaved, ) } - /// Silently persist the profile to the database and OS keyring (no success toast). + /// Like `save_profile_task`, without a success toast. pub(crate) fn persist_profile(&self) -> Task { let profile = self.profile.clone(); self.spawn_write( move || { let conn = db::open()?; - crate::keyring::save_profile(&conn, &profile)?; + let file = &crate::paths::layout()?.credentials; + crate::keyring::save_profile(&conn, file, &profile)?; Ok(()) }, Message::ProfileAutoSaved, ) } - /// Persist settings to the database. - pub(crate) fn persist_settings(&self) -> Task { - let settings = self.settings.clone(); + /// Persist the preferences to `preferences.json`. + pub(crate) fn persist_preferences(&self) -> Task { + let prefs = self.preferences.clone(); self.spawn_write( move || { - let conn = db::open()?; - db::save_settings(&conn, &settings)?; + crate::preferences::save(&crate::paths::layout()?.preferences, &prefs)?; Ok(()) }, - |r| Message::Settings(crate::messages::SettingsMessage::Saved(r)), + |r| Message::Preferences(crate::messages::PreferencesMessage::Saved(r)), ) } @@ -680,9 +711,51 @@ mod tests { assert!(app.data_loaded); assert!(app.load_failed); let toast = app.error_message.as_deref().unwrap(); - assert!(toast.contains("not loaded"), "{toast}"); - assert!(toast.contains("database is locked"), "{toast}"); - assert_eq!(reported_now(&app), Some(Err(NOT_LOADED.to_string()))); + assert_eq!( + toast, + tf("error_load_failed", &[("error", &"database is locked")]) + ); + assert_eq!( + reported_now(&app), + Some(Err(t("error_not_loaded").to_string())) + ); + } + + #[test] + fn without_effects_an_import_lands_at_once() { + let game = |title: &str| Game { + id: None, + title: title.into(), + platform: crate::models::Platform::Steam, + playtime_minutes: 0, + achievements_unlocked: 0, + achievements_total: 0, + status: crate::models::GameStatus::Unplayed, + rating: None, + genre: String::new(), + last_played: String::new(), + cover_url: String::new(), + steam_appid: None, + gog_id: None, + epic_id: None, + xbox_id: None, + psn_id: None, + notes: String::new(), + description: String::new(), + release_date: String::new(), + review_percent: None, + tags: String::new(), + }; + let mut app = Spotter::default(); + + // Effects are a process-wide switch; no other test reads it. + colony_ui::set_effects(false); + let _ = app.handle_import_result("Steam", Ok(vec![game("A"), game("B"), game("C")])); + colony_ui::set_effects(true); + + assert!(app.import_queue.is_empty()); + assert_eq!(app.games.len(), 3); + assert_eq!(app.import_queue_total, 0, "the import is already complete"); } #[test] diff --git a/src/handlers/import.rs b/src/handlers/import.rs index 33a317d..ffe2901 100644 --- a/src/handlers/import.rs +++ b/src/handlers/import.rs @@ -1,19 +1,19 @@ -//! Import message handlers — Steam, GOG, Epic, Xbox, PlayStation, JSON. +//! Import message handlers: Steam, GOG, Epic, Xbox, PlayStation, JSON. //! //! Each platform follows the same dispatch pattern: //! -//! 1. **Trigger** — `Message::Import{Platform}` from the UI starts the import. -//! 2. **Guard** — the handler checks auth prerequisites and dedup (`importing` set). -//! 3. **Spawn** — a background `spawn_task` calls the platform module's +//! 1. **Trigger**: `Message::Import{Platform}` from the UI starts the import. +//! 2. **Guard**: the handler checks auth prerequisites and dedup (`importing` set). +//! 3. **Spawn**: a background `spawn_task` calls the platform module's //! `full_import(auth_params...)` and maps the result to a `*Complete` message. -//! 4. **Complete** — the `*Complete` handler calls `handle_import_result()` which +//! 4. **Complete**: the `*Complete` handler calls `handle_import_result()` which //! merges games, persists, starts the cascade animation, and triggers cover //! downloads. //! //! Auth is separate (`handlers/auth.rs` + `messages::AuthMessage`) and handles //! login flows, code exchange, and proactive token refresh at startup. //! -//! No shared `PlatformImporter` trait — each platform has different auth params +//! No shared `PlatformImporter` trait: each platform has different auth params //! and flows, so the dispatch is kept explicit here. use std::collections::HashMap; @@ -22,8 +22,14 @@ use iced::Task; use crate::app::{Message, Spotter}; use crate::db; +use crate::i18n::{t, tf}; use crate::messages::AuthMessage; +/// The import banner's line while an import of `platform` runs. +fn importing_from(platform: &str) -> Result { + Ok(tf("import_from", &[("platform", &platform)])) +} + impl Spotter { // ── Steam ── @@ -32,7 +38,7 @@ impl Spotter { return Task::none(); } self.steam_login_active = true; - self.import_status = "Opening Steam login in browser...".into(); + self.import_status = Ok(t("import_steam_opening").into()); super::super::app::spawn_task(crate::steam::auth::steam_login, |r| { Message::Auth(AuthMessage::SteamLoginComplete(r)) }) @@ -46,13 +52,16 @@ impl Spotter { match result { Ok(steam_id) => { self.profile.steam_id = steam_id.clone(); - self.import_status = String::new(); - self.show_success(format!("Steam ID detected: {}", steam_id)); + self.import_status = Ok(String::new()); + self.show_success(tf("toast_steam_id", &[("id", &steam_id)])); self.save_profile_task() } Err(e) => { - self.import_status = String::new(); - self.show_error(format!("Steam login failed: {}", e)); + self.import_status = Ok(String::new()); + self.show_error(tf( + "error_login_failed", + &[("platform", &"Steam"), ("error", &e)], + )); Task::none() } } @@ -60,17 +69,14 @@ impl Spotter { pub(crate) fn handle_import_steam(&mut self) -> Task { if self.profile.steam_api_key.is_empty() || self.profile.steam_id.is_empty() { - self.show_error("Steam API key and Steam ID are required. Use 'Login with Steam' to get your ID, then enter your API key."); + self.show_error(t("error_steam_setup")); return Task::none(); } if self.importing.contains("Steam") { return Task::none(); } self.importing.insert("Steam".into()); - self.import_status = format!( - "Importing from Steam... ({} existing games)", - self.games.len() - ); + self.import_status = importing_from("Steam"); eprintln!("[app] Starting Steam import (id={})", self.profile.steam_id); let api_key = self.profile.steam_api_key.clone(); let steam_id = self.profile.steam_id.clone(); @@ -97,7 +103,7 @@ impl Spotter { self.gog_login_active = true; self.gog_code_input.clear(); if let Err(e) = crate::gog::auth::open_login_page() { - self.show_error(format!("Failed to open browser: {}", e)); + self.show_error(tf("error_open_browser", &[("error", &e)])); self.gog_login_active = false; } Task::none() @@ -108,11 +114,11 @@ impl Spotter { let code = match crate::gog::auth::extract_code(&input) { Some(c) => c, None => { - self.show_error("Please paste the URL or code from the browser"); + self.show_error(t("error_gog_code")); return Task::none(); } }; - self.import_status = "Exchanging GOG authorization code...".into(); + self.import_status = Ok(tf("import_checking_login", &[("platform", &"GOG")])); super::super::app::spawn_task( move || crate::gog::auth::exchange_code(&code), |r| Message::Auth(AuthMessage::GogLoginComplete(r)), @@ -129,13 +135,16 @@ impl Spotter { Ok((access_token, refresh_token)) => { self.profile.gog_token = access_token; self.profile.gog_refresh_token = refresh_token; - self.import_status = String::new(); - self.show_success("GOG login successful!"); + self.import_status = Ok(String::new()); + self.show_success(tf("toast_logged_in", &[("platform", &"GOG")])); self.save_profile_task() } Err(e) => { - self.import_status = String::new(); - self.show_error(format!("GOG login failed: {}", e)); + self.import_status = Ok(String::new()); + self.show_error(tf( + "error_login_failed", + &[("platform", &"GOG"), ("error", &e)], + )); Task::none() } } @@ -143,14 +152,14 @@ impl Spotter { pub(crate) fn handle_import_gog(&mut self) -> Task { if self.profile.gog_token.is_empty() { - self.show_error("Please login with GOG first"); + self.show_error(tf("error_login_first", &[("platform", &"GOG")])); return Task::none(); } if self.importing.contains("GOG") { return Task::none(); } self.importing.insert("GOG".into()); - self.import_status = "Importing from GOG...".into(); + self.import_status = importing_from("GOG"); let token = self.profile.gog_token.clone(); let refresh = self.profile.gog_refresh_token.clone(); super::super::app::spawn_task( @@ -195,7 +204,7 @@ impl Spotter { self.epic_login_active = true; self.epic_code_input.clear(); if let Err(e) = crate::epic::auth::open_login_page() { - self.show_error(format!("Failed to open browser: {}", e)); + self.show_error(tf("error_open_browser", &[("error", &e)])); self.epic_login_active = false; } Task::none() @@ -206,13 +215,11 @@ impl Spotter { let code = match crate::epic::auth::extract_code(&input) { Some(c) => c, None => { - self.show_error( - "Please paste the JSON response or authorization code from the browser", - ); + self.show_error(t("error_epic_code")); return Task::none(); } }; - self.import_status = "Exchanging Epic authorization code...".into(); + self.import_status = Ok(tf("import_checking_login", &[("platform", &"Epic Games")])); super::super::app::spawn_task( move || crate::epic::auth::exchange_code(&code), |r| Message::Auth(AuthMessage::EpicLoginComplete(r)), @@ -233,18 +240,20 @@ impl Spotter { if !display_name.is_empty() { self.profile.epic_display_name = display_name.clone(); } - self.import_status = String::new(); - let name = if display_name.is_empty() { - "account linked".to_string() + self.import_status = Ok(String::new()); + self.show_success(if display_name.is_empty() { + tf("toast_logged_in", &[("platform", &"Epic Games")]) } else { - display_name - }; - self.show_success(format!("Epic Games login successful: {}", name)); + tf("toast_epic_logged_in", &[("name", &display_name)]) + }); self.save_profile_task() } Err(e) => { - self.import_status = String::new(); - self.show_error(format!("Epic login failed: {}", e)); + self.import_status = Ok(String::new()); + self.show_error(tf( + "error_login_failed", + &[("platform", &"Epic Games"), ("error", &e)], + )); Task::none() } } @@ -258,7 +267,7 @@ impl Spotter { // If user is logged in with Epic account, use online import if !self.profile.epic_token.is_empty() && !self.profile.epic_account_id.is_empty() { - self.import_status = "Importing from Epic Games account...".into(); + self.import_status = importing_from("Epic Games"); let token = self.profile.epic_token.clone(); let account_id = self.profile.epic_account_id.clone(); let refresh = self.profile.epic_refresh_token.clone(); @@ -268,7 +277,7 @@ impl Spotter { ) } else { // Fallback to local scanning - self.import_status = "Scanning Epic Games Launcher...".into(); + self.import_status = Ok(t("import_epic_scan").into()); super::super::app::spawn_task( || crate::epic::full_import_local().map(|games| (games, None)), Message::EpicImportComplete, @@ -356,16 +365,14 @@ impl Spotter { pub(crate) fn handle_import_xbox(&mut self) -> Task { if self.profile.xbox_api_key.is_empty() { - self.show_error( - "Xbox API key is required. Get one at xbl.io and configure it in Profile.", - ); + self.show_error(t("error_xbox_key")); return Task::none(); } if self.importing.contains("Xbox") { return Task::none(); } self.importing.insert("Xbox".into()); - self.import_status = "Importing from Xbox Live...".into(); + self.import_status = importing_from("Xbox Live"); let api_key = self.profile.xbox_api_key.clone(); super::super::app::spawn_task( move || crate::xbox::full_import(&api_key), @@ -400,14 +407,14 @@ impl Spotter { pub(crate) fn handle_import_playstation(&mut self) -> Task { if self.profile.psn_npsso.is_empty() { - self.show_error("PSN NPSSO token is required. Get it from the PlayStation website."); + self.show_error(t("error_psn_token")); return Task::none(); } if self.importing.contains("PlayStation") { return Task::none(); } self.importing.insert("PlayStation".into()); - self.import_status = "Importing from PlayStation Network...".into(); + self.import_status = importing_from("PlayStation Network"); let npsso = self.profile.psn_npsso.clone(); super::super::app::spawn_task( move || crate::playstation::full_import(&npsso), @@ -422,7 +429,7 @@ impl Spotter { return Task::none(); } self.importing.insert("JSON".into()); - self.import_status = "Importing from JSON backup...".into(); + self.import_status = Ok(t("import_json").into()); super::super::app::spawn_task( || db::import_games_json().map_err(Into::into), Message::ImportJsonComplete, diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 6971394..9187122 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,4 +1,4 @@ pub mod auth; pub mod data; pub mod import; -pub mod settings; +pub mod preferences; diff --git a/src/handlers/preferences.rs b/src/handlers/preferences.rs new file mode 100644 index 0000000..e10432c --- /dev/null +++ b/src/handlers/preferences.rs @@ -0,0 +1,127 @@ +use colony_ui::preferences::Change; +use iced::Task; + +use crate::app::{Message, Screen, Spotter}; +use crate::i18n::{t, tf}; +use crate::messages::PreferencesMessage; + +impl Spotter { + pub(crate) fn handle_preferences(&mut self, msg: PreferencesMessage) -> Task { + let prefs = &mut self.preferences; + match msg { + PreferencesMessage::Toggle => { + if self.screen == Screen::Preferences { + self.screen = + std::mem::replace(&mut self.screen_before_preferences, Screen::Library); + } else { + self.screen_before_preferences = + std::mem::replace(&mut self.screen, Screen::Preferences); + // The page opens on General, every section closed. + self.preferences_category = 0; + self.preference_sections.clear(); + } + return Task::none(); + } + PreferencesMessage::Category(index) => { + self.preferences_category = index; + return Task::none(); + } + PreferencesMessage::ToggleSection(key) => { + if !self.preference_sections.remove(key) { + self.preference_sections.insert(key.to_string()); + } + return Task::none(); + } + PreferencesMessage::Saved(result) => { + if let Err(e) = result { + self.show_error(tf("error_preferences_save", &[("error", &e)])); + } + return Task::none(); + } + PreferencesMessage::Standard(change) => { + prefs.standard.update(change); + if let Change::Theme { .. } = change { + self.show_success(t("theme_applied")); + } + } + PreferencesMessage::Language(language) => { + prefs.language = language; + crate::i18n::set_language(prefs.language.as_deref()); + } + PreferencesMessage::DefaultStatus(status) => prefs.default_status = status, + PreferencesMessage::DefaultPlatform(platform) => prefs.default_platform = platform, + PreferencesMessage::ConfirmDelete => { + prefs.confirm_before_delete = !prefs.confirm_before_delete; + } + PreferencesMessage::StartScreen(screen) => prefs.start_screen = screen, + PreferencesMessage::CompactList => prefs.compact_list = !prefs.compact_list, + PreferencesMessage::ShowCovers => { + prefs.show_covers_in_list = !prefs.show_covers_in_list; + } + PreferencesMessage::ShowStatusLabels => { + prefs.show_status_labels = !prefs.show_status_labels; + } + PreferencesMessage::LargeTargets => { + prefs.large_click_targets = !prefs.large_click_targets; + } + PreferencesMessage::SidebarWidth(width) => prefs.sidebar_width = width.clamp(160, 400), + PreferencesMessage::Notifications => { + prefs.notifications_enabled = !prefs.notifications_enabled; + } + PreferencesMessage::ToastDuration(duration) => prefs.toast_duration = duration, + PreferencesMessage::DateFormat(format) => prefs.date_format = format, + PreferencesMessage::DefaultSortOrder(order) => { + prefs.default_sort_order = order; + self.sort_order = order; + self.invalidate_filter_cache(); + } + PreferencesMessage::RememberFilters => { + prefs.remember_filters = !prefs.remember_filters; + } + PreferencesMessage::ShowDescriptions => { + prefs.show_game_descriptions = !prefs.show_game_descriptions; + } + PreferencesMessage::AchievementsDisplay(display) => { + prefs.achievements_display = display; + } + PreferencesMessage::DownloadCoversAuto => { + prefs.download_covers_auto = !prefs.download_covers_auto; + } + } + self.persist_preferences() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn toggle(app: &mut Spotter) { + let _ = app.handle_preferences(PreferencesMessage::Toggle); + } + + #[test] + fn the_toggle_returns_to_the_screen_it_was_opened_from() { + let mut app = Spotter::default(); + app.screen = Screen::GameDetail(7); + app.preferences_category = 3; + app.preference_sections.insert("theme".into()); + + toggle(&mut app); + assert_eq!(app.screen, Screen::Preferences); + assert_eq!(app.preferences_category, 0, "opens on General"); + assert!(app.preference_sections.is_empty(), "every section closed"); + + toggle(&mut app); + assert_eq!(app.screen, Screen::GameDetail(7)); + } + + #[test] + fn escape_closes_the_page_too() { + let mut app = Spotter::default(); + app.screen = Screen::Statistics; + toggle(&mut app); + let _ = app.update(Message::CancelDelete); + assert_eq!(app.screen, Screen::Statistics); + } +} diff --git a/src/handlers/settings.rs b/src/handlers/settings.rs deleted file mode 100644 index 7f16caf..0000000 --- a/src/handlers/settings.rs +++ /dev/null @@ -1,112 +0,0 @@ -use iced::Task; - -use crate::app::{delay_task, Message, Spotter}; -use crate::messages::SettingsMessage; - -impl Spotter { - pub(crate) fn handle_settings(&mut self, msg: SettingsMessage) -> Task { - match msg { - SettingsMessage::ChangeSection(section) => { - self.settings_section = section; - } - SettingsMessage::DefaultStatus(status) => { - self.settings.default_status = status; - return self.persist_settings(); - } - SettingsMessage::DefaultPlatform(platform) => { - self.settings.default_platform = platform; - return self.persist_settings(); - } - SettingsMessage::ConfirmDelete => { - self.settings.confirm_before_delete = !self.settings.confirm_before_delete; - return self.persist_settings(); - } - SettingsMessage::StartScreen(screen) => { - self.settings.start_screen = screen; - return self.persist_settings(); - } - SettingsMessage::ThemeMode(mode) => { - self.settings.theme_mode = mode; - return self.persist_settings(); - } - SettingsMessage::AccentColor(color) => { - self.settings.accent_color = color; - return self.persist_settings(); - } - SettingsMessage::CompactList => { - self.settings.compact_list = !self.settings.compact_list; - return self.persist_settings(); - } - SettingsMessage::ShowCovers => { - self.settings.show_covers_in_list = !self.settings.show_covers_in_list; - return self.persist_settings(); - } - SettingsMessage::UiScale(scale) => { - self.settings.ui_scale = scale; - return self.persist_settings(); - } - SettingsMessage::HighContrast => { - self.settings.high_contrast = !self.settings.high_contrast; - return self.persist_settings(); - } - SettingsMessage::ShowStatusLabels => { - self.settings.show_status_labels = !self.settings.show_status_labels; - return self.persist_settings(); - } - SettingsMessage::LargeTargets => { - self.settings.large_click_targets = !self.settings.large_click_targets; - return self.persist_settings(); - } - SettingsMessage::SidebarWidth(w) => { - self.settings.sidebar_width = w.clamp(160, 400); - return self.persist_settings(); - } - SettingsMessage::Notifications => { - self.settings.notifications_enabled = !self.settings.notifications_enabled; - return self.persist_settings(); - } - SettingsMessage::ToastDuration(d) => { - self.settings.toast_duration = d; - return self.persist_settings(); - } - SettingsMessage::DateFormat(f) => { - self.settings.date_format = f; - return self.persist_settings(); - } - SettingsMessage::DefaultSortOrder(order) => { - self.settings.default_sort_order = order; - self.sort_order = order; - self.invalidate_filter_cache(); - return self.persist_settings(); - } - SettingsMessage::RememberFilters => { - self.settings.remember_filters = !self.settings.remember_filters; - return self.persist_settings(); - } - SettingsMessage::ShowDescriptions => { - self.settings.show_game_descriptions = !self.settings.show_game_descriptions; - return self.persist_settings(); - } - SettingsMessage::AchievementsDisplay(d) => { - self.settings.achievements_display = d; - return self.persist_settings(); - } - SettingsMessage::DownloadCoversAuto => { - self.settings.download_covers_auto = !self.settings.download_covers_auto; - return self.persist_settings(); - } - SettingsMessage::Saved(result) => { - if let Err(e) = result { - self.show_error(format!("Settings save error: {}", e)); - } else { - self.settings_just_saved = true; - return delay_task(1500, || Message::Settings(SettingsMessage::SaveShown)); - } - } - SettingsMessage::SaveShown => { - self.settings_just_saved = false; - } - } - Task::none() - } -} diff --git a/src/i18n/en.json b/src/i18n/en.json new file mode 100644 index 0000000..a2ae91e --- /dev/null +++ b/src/i18n/en.json @@ -0,0 +1,344 @@ +{ + "app_tagline": "Game tracker", + "nav_label": "Browse", + "nav_library": "Library", + "nav_add_game": "Add game", + "nav_statistics": "Statistics", + "nav_import": "Import", + "nav_profile": "Profile", + "nav_hint": "{modifier}+F to search, Esc to close", + "quick_stats": "Quick stats", + "quick_stat": "{name}: {count}", + "games_count_one": "{count} game", + "games_count_other": "{count} games", + "favorites": "Favorites", + "loading_library": "Loading your library...", + "loading_wait": "Please wait", + "action_delete": "Delete", + "action_delete_count": "Delete ({count})", + "action_cancel": "Cancel", + "action_undo": "Undo", + "action_dismiss": "Dismiss", + "action_export": "Export", + "action_import": "Import", + "status_playing": "Playing", + "status_completed": "Completed", + "status_unplayed": "Unplayed", + "status_dropped": "Dropped", + "status_wishlist": "Wishlist", + "sort_title_asc": "A-Z", + "sort_title_desc": "Z-A", + "sort_playtime": "Playtime", + "sort_rating": "Rating", + "sort_recent": "Recent", + "sort_favorites": "Favorites first", + "date_format_ymd": "YYYY-MM-DD", + "date_format_dmy": "DD/MM/YYYY", + "date_format_mdy": "MM/DD/YYYY", + "toast_duration_short": "Short (2 s)", + "toast_duration_normal": "Normal (4 s)", + "toast_duration_long": "Long (8 s)", + "achievements_display_full": "Full list", + "achievements_display_summary": "Summary", + "achievements_display_hidden": "Hidden", + "playtime_hours": "{hours}h {minutes}m", + "playtime_minutes": "{minutes}m", + "percent": "{value}%", + "rating_none": "Not rated", + "toast_game_deleted": "Game deleted. Click Undo to restore it.", + "toast_games_deleted_one": "{count} game deleted. Click Undo to restore it.", + "toast_games_deleted_other": "{count} games deleted. Click Undo to restore them.", + "toast_games_restored_one": "Game restored.", + "toast_games_restored_other": "{count} games restored.", + "toast_games_status_one": "{count} game set to {status}.", + "toast_games_status_other": "{count} games set to {status}.", + "toast_game_added": "\"{title}\" added to your library.", + "toast_profile_saved": "Profile saved.", + "toast_exported": "Exported to {path}", + "toast_games_imported_one": "{count} game imported.", + "toast_games_imported_other": "{count} games imported.", + "toast_import_updated": "{platform}: {updated} updated", + "toast_steam_id": "Steam ID found: {id}", + "toast_logged_in": "Logged in to {platform}.", + "toast_epic_logged_in": "Logged in to Epic Games as {name}.", + "error_open_url": "Could not open the link: {error}", + "error_open_browser": "Could not open your browser: {error}", + "error_profile_save": "Could not save the profile: {error}", + "error_preferences_save": "Could not save the preferences: {error}", + "error_save": "Could not save: {error}", + "error_export": "Could not export: {error}", + "error_write": "Could not write the file: {error}", + "error_backup": "The automatic backup failed: {error}", + "error_title_required": "Enter a title for the game.", + "error_game_exists": "\"{title}\" is already in your library.", + "error_load_failed": "Your saved data was not loaded ({error}). Nothing will be saved until Spotter restarts and loads it.", + "error_not_loaded": "your saved data was not loaded, so changes are not saved", + "error_login_failed": "{platform} login failed: {error}", + "error_login_first": "Log in to {platform} first.", + "error_login_expired": "Your login has expired. Please log in again.", + "error_steam_setup": "Steam needs your Steam ID and an API key. Log in to get your ID, then enter your API key.", + "error_steam_timeout": "The Steam login timed out after 2 minutes. Please try again.", + "error_steam_signature": "Steam could not confirm that this login came from Steam. Please try again.", + "error_gog_code": "Paste the address or the code the browser shows after you sign in.", + "error_epic_code": "Paste the JSON response or the authorization code the browser shows.", + "error_xbox_key": "Xbox needs an OpenXBL API key. Get a free one at xbl.io and enter it on the Import page.", + "error_psn_token": "PlayStation needs your NPSSO token. Get it from the PlayStation website.", + "error_psn_expired": "PlayStation did not accept the NPSSO token. It may have expired: get a new one.", + "delete_game_title": "Delete game", + "delete_game_question": "Delete \"{title}\" from your library?", + "delete_games_title": "Delete games", + "delete_games_question_one": "Delete {count} game from your library?", + "delete_games_question_other": "Delete {count} games from your library?", + "delete_undo_hint": "You will have 8 seconds to undo.", + "library_title": "Game library", + "library_subtitle": "Track your games across all platforms", + "library_search": "Search by title, genre, tag or note...", + "filter_all_statuses": "All", + "filter_all_platforms": "All", + "library_sort": "Sort:", + "library_select": "Select", + "library_select_done": "Stop selecting", + "library_selected_one": "{count} selected", + "library_selected_other": "{count} selected", + "library_select_all": "All", + "library_select_none": "None", + "library_empty": "No games match your filters", + "library_filter_search": "search \"{query}\"", + "library_filter_status": "status: {status}", + "library_filter_platform": "platform: {platform}", + "library_active_filters": "Active filters: {filters}", + "library_try_broader": "Try a broader search, or", + "library_clear_status": "clear the status filter", + "library_clear_platform": "clear the platform filter", + "library_clear_search": "clear the search", + "library_clear_all": "Clear all filters", + "library_show_more": "Show more ({count} left)", + "library_review": "{percent} positive", + "library_last_played": "Last played: {date}", + "library_playtime": "Playtime: {time}", + "library_no_achievements": "No achievements", + "onboarding_title": "Welcome to Spotter!", + "onboarding_text": "Import your games from Steam, GOG, Epic, Xbox or PlayStation, or add them by hand.", + "onboarding_add": "Add by hand", + "detail_not_found": "Game not found", + "detail_back": "Back to the library", + "detail_no_cover": "No cover image", + "detail_favorite": "Add to favorites", + "detail_favorited": "In your favorites", + "review_overwhelmingly_positive": "Overwhelmingly Positive", + "review_very_positive": "Very Positive", + "review_mostly_positive": "Mostly Positive", + "review_mixed": "Mixed", + "review_mostly_negative": "Mostly Negative", + "review_very_negative": "Very Negative", + "detail_review": "{label} ({percent} positive)", + "detail_no_reviews": "No reviews", + "detail_unknown": "Unknown", + "detail_genre": "Genre", + "detail_release_date": "Release date", + "detail_last_played": "Last played", + "detail_playtime": "Playtime", + "detail_rating": "Rating", + "detail_reviews": "Reviews", + "detail_tags": "Tags:", + "detail_info": "Game info", + "detail_achievements": "Achievements", + "detail_achievements_loading": "Loading achievements...", + "detail_all_unlocked": "All achievements unlocked!", + "detail_keep_going": "Keep going!", + "detail_unlocked_on": "Unlocked {date}", + "detail_no_achievements": "This game has no achievements.", + "detail_notes": "Notes", + "detail_notes_placeholder": "Add notes about this game...", + "detail_notes_full": "limit reached", + "detail_notes_nearly_full": "nearly full", + "detail_change_status": "Change status", + "detail_rating_clear": "Clear", + "detail_rate": "Rate this game", + "add_subtitle": "Add a game to your library by hand", + "add_title_label": "Title *", + "add_title_placeholder": "The game's title", + "add_genre_placeholder": "For example RPG, action, adventure", + "add_platform": "Platform", + "add_status": "Status", + "add_save": "Add to library", + "stats_subtitle": "An overview of your time with games", + "stats_games": "Games", + "stats_total_playtime": "Total playtime", + "stats_average_rating": "Average rating", + "stats_playtime_30_days": "Playtime, last 30 days", + "stats_no_playtime": "No playtime recorded yet. Import games to start tracking.", + "stats_tracked_one": "{time} tracked over {count} day", + "stats_tracked_other": "{time} tracked over {count} days", + "stats_estimated": "Estimated from the games you are playing: no playtime has been recorded yet.", + "stats_by_status": "By status", + "stats_by_platform": "By platform", + "stats_most_played": "Most played", + "stats_same": "No change", + "stats_completed_one": "{count} completed", + "stats_completed_other": "{count} completed", + "stats_completed_change": "{change} completed", + "stats_completed_same": "As many completed", + "stats_this_month": "This month", + "stats_last_month": "Last month", + "stats_change": "Change", + "stats_month_comparison": "Month by month", + "profile_subtitle": "Your account and your data", + "profile_platform_note": "{platform}: set up on the Import page", + "profile_no_keyring": "No system keyring is available: platform tokens are stored unencrypted in Spotter's configuration folder.", + "profile_member_since": "Member since {date}", + "profile_completed": "Completed", + "profile_username": "Username", + "profile_username_placeholder": "Your name in Spotter", + "profile_saved": "Saved", + "profile_save": "Save profile", + "profile_data": "Data and export", + "profile_games_stored_one": "{count} game stored", + "profile_games_stored_other": "{count} games stored", + "profile_database": "Database", + "profile_exports": "Exports", + "profile_covers": "Covers", + "profile_backup_found": "Backup file found. Importing it merges it with your current library.", + "profile_backup_missing": "No backup file yet. Export first to create one.", + "import_title": "Import games", + "import_subtitle": "Bring in your game library from Steam, GOG and more", + "import_from": "Importing from {platform}...", + "import_steam_opening": "Opening the Steam login in your browser...", + "import_checking_login": "Checking the {platform} login...", + "import_epic_scan": "Looking for games installed with the Epic Games Launcher...", + "import_json": "Importing the JSON backup...", + "import_adding_one": "{platform}: adding {count} game...", + "import_adding_other": "{platform}: adding {count} games...", + "import_updated": "{platform}: {found} found, {updated} updated", + "import_progress": "Importing... {done}/{total}", + "import_complete_one": "Import complete: {count} game added", + "import_complete_other": "Import complete: {count} games added", + "import_failed": "{platform} import failed: {error}", + "import_log_done": "{platform}: {found} found, {added} new, {updated} updated", + "import_ready": "Ready", + "import_key_needed": "API key needed", + "import_setup_needed": "Setup needed", + "import_logging_in": "Logging in...", + "import_connected": "Connected", + "import_local_only": "Local only", + "import_importing": "Importing...", + "import_manual_only": "By hand only", + "import_step_login": "Log in", + "import_step_api_key": "API key", + "import_step_paste_address": "Paste the address", + "import_step_paste_code": "Paste the code", + "import_step_gamertag": "Gamertag", + "import_step_npsso": "NPSSO token", + "import_steam_id": "ID:", + "import_not_logged_in": "Not logged in", + "import_logged_in": "Logged in", + "import_waiting": "Waiting...", + "import_waiting_login": "Waiting for you to log in...", + "import_login": "Log in", + "import_login_again": "Log in again", + "import_finish_login": "Finish", + "import_platform": "Import {platform}", + "import_steam_key_placeholder": "Steam Web API key", + "import_gog_hint": "After signing in, copy the address of the page you land on.", + "import_gog_placeholder": "Paste that address here", + "import_account": "Account:", + "import_epic_optional": "Optional: without logging in, only installed games are found.", + "import_epic_hint": "Copy the JSON response (or the authorization code) the browser page shows.", + "import_epic_placeholder": "Paste the JSON or the authorization code here", + "import_epic_full": "Import the whole library", + "import_epic_local": "Import installed games", + "import_epic_full_note": "Imports every game your Epic account owns.", + "import_epic_local_note": "Only installed games. Log in to import the whole library.", + "import_xbox_key_placeholder": "Your OpenXBL API key, from xbl.io", + "import_xbox_key_link": "Get a free API key at xbl.io", + "import_xbox_gamertag_placeholder": "Your Xbox gamertag", + "import_psn_placeholder": "Your NPSSO token, from ca.account.sony.com", + "import_psn_link": "Get your token at ca.account.sony.com", + "import_nintendo_no_api": "Nintendo offers no public API for game libraries.", + "import_nintendo_manual": "Add your Nintendo games by hand on the Add game page.", + "import_log": "Import log", + "import_log_entries_one": "{count} entry", + "import_log_entries_other": "{count} entries", + "import_guide": "Setup guide", + "import_guide_subtitle": "Quick reference", + "import_tip_steam": "Log in to get your ID, then enter your API key from steamcommunity.com/dev/apikey.", + "import_tip_gog": "Click Log in, sign in, then paste the address you land on.", + "import_tip_epic": "Log in to import the whole library, or import the installed games only.", + "import_tip_xbox": "Get a free API key at xbl.io, enter it above, then import.", + "import_tip_playstation": "Get your NPSSO token at ca.account.sony.com and enter it above.", + "import_tip_nintendo": "No API: add games by hand on the Add game page.", + "import_note_reimport": "Importing again updates the games you have and adds the new ones, without duplicates.", + "import_note_covers": "Covers are downloaded after an import, unless you turned that off in Preferences.", + "import_note_backups": "You can export and import JSON backups on the Profile page.", + "steam_callback_ok_title": "Logged in to Steam", + "steam_callback_ok_text": "Spotter has your Steam ID. You can close this tab and go back to Spotter.", + "steam_callback_failed_title": "Login failed", + "steam_callback_failed_text": "Steam could not confirm the login. Please try again.", + "preferences_cat_library": "Library", + "preferences_library_desc": "How games are added, sorted and shown.", + "preferences_cat_imports": "Imports", + "preferences_imports_desc": "What happens after a library import.", + "preferences_section_language": "Language", + "preferences_language_desc": "The language of Spotter's interface. Game titles and descriptions stay as the platforms send them.", + "preferences_language": "Interface language", + "preferences_language_choice_desc": "Follows the system until you pick one.", + "preferences_language_system": "System language", + "preferences_section_startup": "Startup", + "preferences_startup_desc": "What Spotter shows when it opens.", + "preferences_start_screen": "Start screen", + "preferences_start_screen_desc": "The screen Spotter opens on.", + "preferences_section_notifications": "Notifications", + "preferences_notifications_desc": "The short messages at the bottom of the window that confirm an action.", + "preferences_show_notifications": "Show notifications", + "preferences_show_notifications_desc": "Confirm finished actions. Errors always show.", + "preferences_toast_duration": "Duration", + "preferences_toast_duration_desc": "How long a notification stays on screen.", + "preferences_section_dates": "Dates", + "preferences_dates_desc": "How dates are written across Spotter.", + "preferences_date_format": "Date format", + "preferences_date_format_desc": "Release dates, last played and achievement unlocks.", + "preferences_section_layout": "Layout", + "preferences_layout_desc": "How much room the sidebar and the library list take.", + "preferences_sidebar_width": "Sidebar width", + "preferences_sidebar_width_desc": "Room for the navigation on the left.", + "preferences_sidebar_narrow": "Narrow", + "preferences_sidebar_normal": "Normal", + "preferences_sidebar_wide": "Wide", + "preferences_compact_list": "Compact library list", + "preferences_compact_list_desc": "Less space between games, so more of them fit on screen.", + "preferences_show_covers": "Covers in the library", + "preferences_show_covers_desc": "Show each game's cover next to its title.", + "preferences_section_color_cues": "Color cues", + "preferences_color_cues_desc": "Information that would otherwise rest on color alone.", + "preferences_status_labels": "Show status labels", + "preferences_status_labels_desc": "Write each game's status next to it instead of a colored dot.", + "preferences_section_navigation": "Navigation", + "preferences_navigation_desc": "How easy buttons are to hit.", + "preferences_large_targets": "Large click targets", + "preferences_large_targets_desc": "Taller buttons and chips, easier to click.", + "preferences_section_new_games": "New games", + "preferences_new_games_desc": "What a game you add by hand starts with.", + "preferences_default_status": "Default status", + "preferences_default_platform": "Default platform", + "preferences_preselected_desc": "Already picked when you add a game by hand.", + "preferences_section_sorting": "Sorting and filters", + "preferences_sorting_desc": "How the library is ordered when Spotter opens.", + "preferences_sort_order": "Sort order", + "preferences_sort_order_desc": "Also changes when you pick another order in the library.", + "preferences_remember_filters": "Remember filters", + "preferences_remember_filters_desc": "Keep the status and platform filters from one session to the next.", + "preferences_section_game_details": "Game details", + "preferences_game_details_desc": "What the library cards and the game pages show.", + "preferences_show_descriptions": "Descriptions in the library", + "preferences_show_descriptions_desc": "Show the start of each game's description on its card.", + "preferences_achievements_display": "Achievements", + "preferences_achievements_display_desc": "How much of a game's achievements its page shows.", + "preferences_section_deleting": "Deleting", + "preferences_deleting_desc": "A deleted game can be restored for 8 seconds.", + "preferences_confirm_delete": "Confirm before deleting", + "preferences_confirm_delete_desc": "Ask before a game leaves the library.", + "preferences_section_covers": "Covers", + "preferences_covers_desc": "Cover images come from the platforms and are kept in Spotter's cache.", + "preferences_auto_covers": "Download covers after an import", + "preferences_auto_covers_desc": "Fetch the missing covers as soon as an import finishes." +} diff --git a/src/i18n/fr.json b/src/i18n/fr.json new file mode 100644 index 0000000..b04b94c --- /dev/null +++ b/src/i18n/fr.json @@ -0,0 +1,344 @@ +{ + "app_tagline": "Suivi de jeux", + "nav_label": "Parcourir", + "nav_library": "Bibliothèque", + "nav_add_game": "Ajouter un jeu", + "nav_statistics": "Statistiques", + "nav_import": "Importer", + "nav_profile": "Profil", + "nav_hint": "{modifier}+F pour chercher, Échap pour fermer", + "quick_stats": "En bref", + "quick_stat": "{name}\u00a0: {count}", + "games_count_one": "{count} jeu", + "games_count_other": "{count} jeux", + "favorites": "Favoris", + "loading_library": "Chargement de votre bibliothèque...", + "loading_wait": "Veuillez patienter", + "action_delete": "Supprimer", + "action_delete_count": "Supprimer ({count})", + "action_cancel": "Annuler", + "action_undo": "Annuler", + "action_dismiss": "Masquer", + "action_export": "Exporter", + "action_import": "Importer", + "status_playing": "En cours", + "status_completed": "Terminé", + "status_unplayed": "Non commencé", + "status_dropped": "Abandonné", + "status_wishlist": "Liste de souhaits", + "sort_title_asc": "A-Z", + "sort_title_desc": "Z-A", + "sort_playtime": "Temps de jeu", + "sort_rating": "Note", + "sort_recent": "Récents", + "sort_favorites": "Favoris d'abord", + "date_format_ymd": "AAAA-MM-JJ", + "date_format_dmy": "JJ/MM/AAAA", + "date_format_mdy": "MM/JJ/AAAA", + "toast_duration_short": "Courte (2\u00a0s)", + "toast_duration_normal": "Normale (4\u00a0s)", + "toast_duration_long": "Longue (8\u00a0s)", + "achievements_display_full": "Liste complète", + "achievements_display_summary": "Résumé", + "achievements_display_hidden": "Masqués", + "playtime_hours": "{hours}\u00a0h\u00a0{minutes}", + "playtime_minutes": "{minutes}\u00a0min", + "percent": "{value}\u00a0%", + "rating_none": "Non noté", + "toast_game_deleted": "Jeu supprimé. Cliquez sur Annuler pour le restaurer.", + "toast_games_deleted_one": "{count} jeu supprimé. Cliquez sur Annuler pour le restaurer.", + "toast_games_deleted_other": "{count} jeux supprimés. Cliquez sur Annuler pour les restaurer.", + "toast_games_restored_one": "Jeu restauré.", + "toast_games_restored_other": "{count} jeux restaurés.", + "toast_games_status_one": "{count} jeu passé en «\u00a0{status}\u00a0».", + "toast_games_status_other": "{count} jeux passés en «\u00a0{status}\u00a0».", + "toast_game_added": "«\u00a0{title}\u00a0» ajouté à votre bibliothèque.", + "toast_profile_saved": "Profil enregistré.", + "toast_exported": "Exporté vers {path}", + "toast_games_imported_one": "{count} jeu importé.", + "toast_games_imported_other": "{count} jeux importés.", + "toast_import_updated": "{platform}\u00a0: {updated} mis à jour", + "toast_steam_id": "Identifiant Steam trouvé\u00a0: {id}", + "toast_logged_in": "Connecté à {platform}.", + "toast_epic_logged_in": "Connecté à Epic Games en tant que {name}.", + "error_open_url": "Impossible d'ouvrir le lien\u00a0: {error}", + "error_open_browser": "Impossible d'ouvrir votre navigateur\u00a0: {error}", + "error_profile_save": "Impossible d'enregistrer le profil\u00a0: {error}", + "error_preferences_save": "Impossible d'enregistrer les préférences\u00a0: {error}", + "error_save": "Échec de l'enregistrement\u00a0: {error}", + "error_export": "Échec de l'exportation\u00a0: {error}", + "error_write": "Impossible d'écrire le fichier\u00a0: {error}", + "error_backup": "Échec de la sauvegarde automatique\u00a0: {error}", + "error_title_required": "Saisissez le titre du jeu.", + "error_game_exists": "«\u00a0{title}\u00a0» est déjà dans votre bibliothèque.", + "error_load_failed": "Vos données enregistrées n'ont pas été chargées ({error}). Rien ne sera enregistré tant que Spotter ne les aura pas chargées à un prochain démarrage.", + "error_not_loaded": "vos données enregistrées n'ont pas été chargées, les modifications ne sont donc pas enregistrées", + "error_login_failed": "Échec de la connexion à {platform}\u00a0: {error}", + "error_login_first": "Connectez-vous d'abord à {platform}.", + "error_login_expired": "Votre connexion a expiré. Veuillez vous reconnecter.", + "error_steam_setup": "Steam a besoin de votre identifiant Steam et d'une clé d'API. Connectez-vous pour obtenir votre identifiant, puis saisissez votre clé d'API.", + "error_steam_timeout": "La connexion à Steam a expiré au bout de 2\u00a0minutes. Veuillez réessayer.", + "error_steam_signature": "Steam n'a pas pu confirmer que cette connexion venait bien de Steam. Veuillez réessayer.", + "error_gog_code": "Collez l'adresse ou le code qu'affiche le navigateur après la connexion.", + "error_epic_code": "Collez la réponse JSON ou le code d'autorisation qu'affiche le navigateur.", + "error_xbox_key": "Xbox a besoin d'une clé d'API OpenXBL. Obtenez-en une gratuitement sur xbl.io et saisissez-la sur la page Importer.", + "error_psn_token": "PlayStation a besoin de votre jeton NPSSO. Récupérez-le sur le site de PlayStation.", + "error_psn_expired": "PlayStation n'a pas accepté le jeton NPSSO. Il a peut-être expiré\u00a0: récupérez-en un nouveau.", + "delete_game_title": "Supprimer le jeu", + "delete_game_question": "Supprimer «\u00a0{title}\u00a0» de votre bibliothèque\u00a0?", + "delete_games_title": "Supprimer des jeux", + "delete_games_question_one": "Supprimer {count} jeu de votre bibliothèque\u00a0?", + "delete_games_question_other": "Supprimer {count} jeux de votre bibliothèque\u00a0?", + "delete_undo_hint": "Vous aurez 8\u00a0secondes pour annuler.", + "library_title": "Bibliothèque de jeux", + "library_subtitle": "Suivez vos jeux sur toutes les plateformes", + "library_search": "Chercher par titre, genre, étiquette ou note...", + "filter_all_statuses": "Tous", + "filter_all_platforms": "Toutes", + "library_sort": "Trier\u00a0:", + "library_select": "Sélectionner", + "library_select_done": "Terminer la sélection", + "library_selected_one": "{count} sélectionné", + "library_selected_other": "{count} sélectionnés", + "library_select_all": "Tout", + "library_select_none": "Aucun", + "library_empty": "Aucun jeu ne correspond à vos filtres", + "library_filter_search": "recherche «\u00a0{query}\u00a0»", + "library_filter_status": "statut\u00a0: {status}", + "library_filter_platform": "plateforme\u00a0: {platform}", + "library_active_filters": "Filtres actifs\u00a0: {filters}", + "library_try_broader": "Essayez une recherche plus large, ou", + "library_clear_status": "retirez le filtre de statut", + "library_clear_platform": "retirez le filtre de plateforme", + "library_clear_search": "effacez la recherche", + "library_clear_all": "Retirer tous les filtres", + "library_show_more": "Afficher la suite ({count})", + "library_review": "{percent} d'avis positifs", + "library_last_played": "Dernière partie\u00a0: {date}", + "library_playtime": "Temps de jeu\u00a0: {time}", + "library_no_achievements": "Aucun succès", + "onboarding_title": "Bienvenue dans Spotter\u00a0!", + "onboarding_text": "Importez vos jeux depuis Steam, GOG, Epic, Xbox ou PlayStation, ou ajoutez-les à la main.", + "onboarding_add": "Ajouter à la main", + "detail_not_found": "Jeu introuvable", + "detail_back": "Retour à la bibliothèque", + "detail_no_cover": "Pas de jaquette", + "detail_favorite": "Ajouter aux favoris", + "detail_favorited": "Dans vos favoris", + "review_overwhelmingly_positive": "Extrêmement positives", + "review_very_positive": "Très positives", + "review_mostly_positive": "Plutôt positives", + "review_mixed": "Moyennes", + "review_mostly_negative": "Plutôt négatives", + "review_very_negative": "Très négatives", + "detail_review": "{label} ({percent} positives)", + "detail_no_reviews": "Aucune évaluation", + "detail_unknown": "Inconnue", + "detail_genre": "Genre", + "detail_release_date": "Date de sortie", + "detail_last_played": "Dernière partie", + "detail_playtime": "Temps de jeu", + "detail_rating": "Note", + "detail_reviews": "Évaluations", + "detail_tags": "Étiquettes\u00a0:", + "detail_info": "Informations", + "detail_achievements": "Succès", + "detail_achievements_loading": "Chargement des succès...", + "detail_all_unlocked": "Tous les succès sont débloqués\u00a0!", + "detail_keep_going": "Continuez\u00a0!", + "detail_unlocked_on": "Débloqué le {date}", + "detail_no_achievements": "Ce jeu n'a pas de succès.", + "detail_notes": "Notes", + "detail_notes_placeholder": "Ajoutez des notes sur ce jeu...", + "detail_notes_full": "limite atteinte", + "detail_notes_nearly_full": "presque plein", + "detail_change_status": "Changer de statut", + "detail_rating_clear": "Effacer", + "detail_rate": "Noter ce jeu", + "add_subtitle": "Ajoutez un jeu à votre bibliothèque à la main", + "add_title_label": "Titre *", + "add_title_placeholder": "Le titre du jeu", + "add_genre_placeholder": "Par exemple RPG, action, aventure", + "add_platform": "Plateforme", + "add_status": "Statut", + "add_save": "Ajouter à la bibliothèque", + "stats_subtitle": "Un aperçu de votre temps de jeu", + "stats_games": "Jeux", + "stats_total_playtime": "Temps de jeu total", + "stats_average_rating": "Note moyenne", + "stats_playtime_30_days": "Temps de jeu, 30 derniers jours", + "stats_no_playtime": "Aucun temps de jeu enregistré pour l'instant. Importez des jeux pour commencer le suivi.", + "stats_tracked_one": "{time} enregistrés sur {count} jour", + "stats_tracked_other": "{time} enregistrés sur {count} jours", + "stats_estimated": "Estimé d'après les jeux en cours\u00a0: aucun temps de jeu n'a encore été enregistré.", + "stats_by_status": "Par statut", + "stats_by_platform": "Par plateforme", + "stats_most_played": "Les plus joués", + "stats_same": "Inchangé", + "stats_completed_one": "{count} terminé", + "stats_completed_other": "{count} terminés", + "stats_completed_change": "{change} terminé(s)", + "stats_completed_same": "Autant de jeux terminés", + "stats_this_month": "Ce mois-ci", + "stats_last_month": "Le mois dernier", + "stats_change": "Évolution", + "stats_month_comparison": "Mois par mois", + "profile_subtitle": "Votre compte et vos données", + "profile_platform_note": "{platform}\u00a0: se configure sur la page Importer", + "profile_no_keyring": "Aucun trousseau système n'est disponible\u00a0: les jetons des plateformes sont stockés non chiffrés dans le dossier de configuration de Spotter.", + "profile_member_since": "Membre depuis le {date}", + "profile_completed": "Terminés", + "profile_username": "Nom d'utilisateur", + "profile_username_placeholder": "Votre nom dans Spotter", + "profile_saved": "Enregistré", + "profile_save": "Enregistrer le profil", + "profile_data": "Données et exportation", + "profile_games_stored_one": "{count} jeu enregistré", + "profile_games_stored_other": "{count} jeux enregistrés", + "profile_database": "Base de données", + "profile_exports": "Exportations", + "profile_covers": "Jaquettes", + "profile_backup_found": "Sauvegarde trouvée. L'importer la fusionne avec votre bibliothèque actuelle.", + "profile_backup_missing": "Pas encore de sauvegarde. Exportez d'abord pour en créer une.", + "import_title": "Importer des jeux", + "import_subtitle": "Importez votre bibliothèque depuis Steam, GOG et d'autres", + "import_from": "Importation depuis {platform}...", + "import_steam_opening": "Ouverture de la connexion à Steam dans votre navigateur...", + "import_checking_login": "Vérification de la connexion à {platform}...", + "import_epic_scan": "Recherche des jeux installés avec l'Epic Games Launcher...", + "import_json": "Importation de la sauvegarde JSON...", + "import_adding_one": "{platform}\u00a0: ajout de {count} jeu...", + "import_adding_other": "{platform}\u00a0: ajout de {count} jeux...", + "import_updated": "{platform}\u00a0: {found} au total, {updated} mis à jour", + "import_progress": "Importation... {done}/{total}", + "import_complete_one": "Importation terminée\u00a0: {count} jeu ajouté", + "import_complete_other": "Importation terminée\u00a0: {count} jeux ajoutés", + "import_failed": "Échec de l'importation depuis {platform}\u00a0: {error}", + "import_log_done": "{platform}\u00a0: {found} au total, {added} nouveau(x), {updated} mis à jour", + "import_ready": "Prêt", + "import_key_needed": "Clé d'API requise", + "import_setup_needed": "À configurer", + "import_logging_in": "Connexion...", + "import_connected": "Connecté", + "import_local_only": "Local uniquement", + "import_importing": "Importation...", + "import_manual_only": "À la main uniquement", + "import_step_login": "Connexion", + "import_step_api_key": "Clé d'API", + "import_step_paste_address": "Coller l'adresse", + "import_step_paste_code": "Coller le code", + "import_step_gamertag": "Gamertag", + "import_step_npsso": "Jeton NPSSO", + "import_steam_id": "Identifiant\u00a0:", + "import_not_logged_in": "Non connecté", + "import_logged_in": "Connecté", + "import_waiting": "En attente...", + "import_waiting_login": "En attente de votre connexion...", + "import_login": "Se connecter", + "import_login_again": "Se reconnecter", + "import_finish_login": "Terminer", + "import_platform": "Importer {platform}", + "import_steam_key_placeholder": "Clé Steam Web API", + "import_gog_hint": "Après la connexion, copiez l'adresse de la page où vous arrivez.", + "import_gog_placeholder": "Collez cette adresse ici", + "import_account": "Compte\u00a0:", + "import_epic_optional": "Facultatif\u00a0: sans connexion, seuls les jeux installés sont trouvés.", + "import_epic_hint": "Copiez la réponse JSON (ou le code d'autorisation) qu'affiche la page du navigateur.", + "import_epic_placeholder": "Collez le JSON ou le code d'autorisation ici", + "import_epic_full": "Importer toute la bibliothèque", + "import_epic_local": "Importer les jeux installés", + "import_epic_full_note": "Importe tous les jeux que possède votre compte Epic.", + "import_epic_local_note": "Seulement les jeux installés. Connectez-vous pour importer toute la bibliothèque.", + "import_xbox_key_placeholder": "Votre clé d'API OpenXBL, depuis xbl.io", + "import_xbox_key_link": "Obtenez une clé d'API gratuite sur xbl.io", + "import_xbox_gamertag_placeholder": "Votre gamertag Xbox", + "import_psn_placeholder": "Votre jeton NPSSO, depuis ca.account.sony.com", + "import_psn_link": "Obtenez votre jeton sur ca.account.sony.com", + "import_nintendo_no_api": "Nintendo ne propose pas d'API publique pour les bibliothèques de jeux.", + "import_nintendo_manual": "Ajoutez vos jeux Nintendo à la main sur la page Ajouter un jeu.", + "import_log": "Journal des importations", + "import_log_entries_one": "{count} entrée", + "import_log_entries_other": "{count} entrées", + "import_guide": "Guide de configuration", + "import_guide_subtitle": "Aide-mémoire", + "import_tip_steam": "Connectez-vous pour obtenir votre identifiant, puis saisissez votre clé d'API depuis steamcommunity.com/dev/apikey.", + "import_tip_gog": "Cliquez sur Se connecter, connectez-vous, puis collez l'adresse où vous arrivez.", + "import_tip_epic": "Connectez-vous pour importer toute la bibliothèque, ou importez seulement les jeux installés.", + "import_tip_xbox": "Obtenez une clé d'API gratuite sur xbl.io, saisissez-la ci-dessus, puis importez.", + "import_tip_playstation": "Récupérez votre jeton NPSSO sur ca.account.sony.com et saisissez-le ci-dessus.", + "import_tip_nintendo": "Pas d'API\u00a0: ajoutez les jeux à la main sur la page Ajouter un jeu.", + "import_note_reimport": "Réimporter met à jour vos jeux et ajoute les nouveaux, sans doublons.", + "import_note_covers": "Les jaquettes sont téléchargées après une importation, sauf si vous l'avez désactivé dans les Préférences.", + "import_note_backups": "Vous pouvez exporter et importer des sauvegardes JSON sur la page Profil.", + "steam_callback_ok_title": "Connecté à Steam", + "steam_callback_ok_text": "Spotter a votre identifiant Steam. Vous pouvez fermer cet onglet et revenir à Spotter.", + "steam_callback_failed_title": "Échec de la connexion", + "steam_callback_failed_text": "Steam n'a pas pu confirmer la connexion. Veuillez réessayer.", + "preferences_cat_library": "Bibliothèque", + "preferences_library_desc": "Comment les jeux sont ajoutés, triés et affichés.", + "preferences_cat_imports": "Importations", + "preferences_imports_desc": "Ce qui se passe après l'importation d'une bibliothèque.", + "preferences_section_language": "Langue", + "preferences_language_desc": "La langue de l'interface de Spotter. Les titres et les descriptions des jeux restent tels que les plateformes les envoient.", + "preferences_language": "Langue de l'interface", + "preferences_language_choice_desc": "Suit celle du système tant que vous n'en choisissez pas une.", + "preferences_language_system": "Langue du système", + "preferences_section_startup": "Démarrage", + "preferences_startup_desc": "Ce que Spotter affiche à l'ouverture.", + "preferences_start_screen": "Écran de démarrage", + "preferences_start_screen_desc": "L'écran sur lequel Spotter s'ouvre.", + "preferences_section_notifications": "Notifications", + "preferences_notifications_desc": "Les courts messages en bas de la fenêtre qui confirment une action.", + "preferences_show_notifications": "Afficher les notifications", + "preferences_show_notifications_desc": "Confirme les actions terminées. Les erreurs s'affichent toujours.", + "preferences_toast_duration": "Durée", + "preferences_toast_duration_desc": "Combien de temps une notification reste à l'écran.", + "preferences_section_dates": "Dates", + "preferences_dates_desc": "Comment les dates sont écrites dans Spotter.", + "preferences_date_format": "Format de date", + "preferences_date_format_desc": "Dates de sortie, dernières parties et déblocages de succès.", + "preferences_section_layout": "Disposition", + "preferences_layout_desc": "La place que prennent la barre latérale et la liste de la bibliothèque.", + "preferences_sidebar_width": "Largeur de la barre latérale", + "preferences_sidebar_width_desc": "La place de la navigation, à gauche.", + "preferences_sidebar_narrow": "Étroite", + "preferences_sidebar_normal": "Normale", + "preferences_sidebar_wide": "Large", + "preferences_compact_list": "Liste compacte", + "preferences_compact_list_desc": "Moins d'espace entre les jeux, pour en voir plus à l'écran.", + "preferences_show_covers": "Jaquettes dans la bibliothèque", + "preferences_show_covers_desc": "Affiche la jaquette de chaque jeu à côté de son titre.", + "preferences_section_color_cues": "Repères de couleur", + "preferences_color_cues_desc": "Les informations qui reposeraient sinon sur la seule couleur.", + "preferences_status_labels": "Afficher les statuts en toutes lettres", + "preferences_status_labels_desc": "Écrit le statut de chaque jeu à côté de lui au lieu d'un point de couleur.", + "preferences_section_navigation": "Navigation", + "preferences_navigation_desc": "La facilité avec laquelle on atteint les boutons.", + "preferences_large_targets": "Grandes zones de clic", + "preferences_large_targets_desc": "Des boutons et des pastilles plus hauts, plus faciles à cliquer.", + "preferences_section_new_games": "Nouveaux jeux", + "preferences_new_games_desc": "Les valeurs de départ d'un jeu ajouté à la main.", + "preferences_default_status": "Statut par défaut", + "preferences_default_platform": "Plateforme par défaut", + "preferences_preselected_desc": "Déjà choisi quand vous ajoutez un jeu à la main.", + "preferences_section_sorting": "Tri et filtres", + "preferences_sorting_desc": "L'ordre de la bibliothèque à l'ouverture de Spotter.", + "preferences_sort_order": "Ordre de tri", + "preferences_sort_order_desc": "Change aussi quand vous choisissez un autre ordre dans la bibliothèque.", + "preferences_remember_filters": "Mémoriser les filtres", + "preferences_remember_filters_desc": "Conserve les filtres de statut et de plateforme d'une session à l'autre.", + "preferences_section_game_details": "Détails des jeux", + "preferences_game_details_desc": "Ce qu'affichent les cartes de la bibliothèque et les pages des jeux.", + "preferences_show_descriptions": "Descriptions dans la bibliothèque", + "preferences_show_descriptions_desc": "Affiche le début de la description de chaque jeu sur sa carte.", + "preferences_achievements_display": "Succès", + "preferences_achievements_display_desc": "La part des succès d'un jeu que sa page affiche.", + "preferences_section_deleting": "Suppression", + "preferences_deleting_desc": "Un jeu supprimé peut être restauré pendant 8\u00a0secondes.", + "preferences_confirm_delete": "Confirmer avant de supprimer", + "preferences_confirm_delete_desc": "Demande confirmation avant qu'un jeu quitte la bibliothèque.", + "preferences_section_covers": "Jaquettes", + "preferences_covers_desc": "Les jaquettes viennent des plateformes et sont gardées dans le cache de Spotter.", + "preferences_auto_covers": "Télécharger les jaquettes après une importation", + "preferences_auto_covers_desc": "Récupère les jaquettes manquantes dès qu'une importation se termine." +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..ab6fd0b --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,235 @@ +//! Spotter's interface strings, in English and French. +//! +//! Spotter's own strings live in `en.json` and `fr.json` next to this file. +//! The strings every Colony program shares (the Preferences skeleton, theme +//! and accent names) come from `colony_ui::i18n`, and [`t`] reads both, so a +//! view never needs to know which table a key is in. +//! +//! English is canonical: a key is named after its English string and written +//! in English first. Both files hold the same keys; the tests below fail the +//! build when they drift. + +use std::collections::HashMap; +use std::fmt::Display; +use std::sync::OnceLock; + +use colony_ui::i18n::{self as shared, Locale}; + +type Table = HashMap; + +fn table(locale: Locale) -> &'static Table { + static EN: OnceLock = OnceLock::new(); + static FR: OnceLock
= OnceLock::new(); + let (cell, json) = match locale { + Locale::En => (&EN, include_str!("en.json")), + Locale::Fr => (&FR, include_str!("fr.json")), + }; + // Both files are parsed by the tests, so this cannot fail in a build + // that passed them. + cell.get_or_init(|| serde_json::from_str(json).expect("the locale files are valid JSON")) +} + +/// The string for `key` in the active locale. Looks in Spotter's table, then +/// in its English, then in the shared Colony labels, which end with the key +/// itself: a missing string shows up as its key rather than as a blank. +pub fn t(key: &str) -> &'static str { + table(shared::locale()) + .get(key) + .or_else(|| table(Locale::En).get(key)) + .map_or_else(|| shared::t(key), String::as_str) +} + +/// [`t`] with every `{name}` replaced by its value. +pub fn tf(key: &str, vars: &[(&str, &dyn Display)]) -> String { + vars.iter().fold(t(key).to_owned(), |text, (name, value)| { + text.replace(&format!("{{{name}}}"), &value.to_string()) + }) +} + +/// A string that depends on a count: `{key}_one` for a single thing (and for +/// none, in French), `{key}_other` otherwise. `{count}` and `vars` are filled. +pub fn tn(key: &str, count: usize, vars: &[(&str, &dyn Display)]) -> String { + let one = match shared::locale() { + Locale::En => count == 1, + Locale::Fr => count <= 1, + }; + let key = format!("{key}_{}", if one { "one" } else { "other" }); + let text = tf(&key, &[("count", &count)]); + vars.iter().fold(text, |text, (name, value)| { + text.replace(&format!("{{{name}}}"), &value.to_string()) + }) +} + +/// `value` with `places` decimals and the interface language's decimal +/// separator (a comma in French). +pub fn decimal(value: f32, places: usize) -> String { + let text = format!("{value:.places$}"); + match shared::locale() { + Locale::En => text, + Locale::Fr => text.replace('.', ","), + } +} + +/// A percentage, written the interface language's way ("50%", "50 %"). +pub fn percent(value: f32, places: usize) -> String { + tf("percent", &[("value", &decimal(value, places))]) +} + +/// Switch the interface language. `language` is the saved choice ("en", +/// "fr"); `None` follows the system. +pub fn set_language(language: Option<&str>) { + let tag = language.map(str::to_owned).unwrap_or_else(system_language); + shared::set_locale(Locale::from_tag(&tag)); +} + +/// The system language, as the POSIX locale variables give it, most specific +/// first. Empty when none is set (on Windows and macOS, usually), which means +/// English. +fn system_language() -> String { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .iter() + .filter_map(|var| std::env::var(var).ok()) + .find(|value| !value.is_empty()) + .unwrap_or_default() +} + +/// The languages Spotter's interface ships in, by their saved tag and their +/// own name (a language is always offered in itself). +pub const LANGUAGES: [(&str, &str); 2] = [("en", "English"), ("fr", "Français")]; + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + const EN: &str = include_str!("en.json"); + const FR: &str = include_str!("fr.json"); + + fn keys(table: &Table) -> BTreeSet<&str> { + table.keys().map(String::as_str).collect() + } + + fn placeholders(text: &str) -> BTreeSet<&str> { + text.split('{') + .skip(1) + .filter_map(|rest| rest.split_once('}').map(|(name, _)| name)) + .collect() + } + + #[test] + fn fr_and_en_have_identical_key_sets() { + let (en, fr) = (keys(table(Locale::En)), keys(table(Locale::Fr))); + assert_eq!( + en.difference(&fr).collect::>(), + Vec::<&&str>::new(), + "missing in fr.json" + ); + assert_eq!( + fr.difference(&en).collect::>(), + Vec::<&&str>::new(), + "missing in en.json" + ); + } + + #[test] + fn no_string_is_empty_and_both_locales_fill_the_same_placeholders() { + for (key, en) in table(Locale::En) { + let fr = &table(Locale::Fr)[key]; + assert!(!en.trim().is_empty() && !fr.trim().is_empty(), "{key}"); + assert_eq!(placeholders(en), placeholders(fr), "{key}"); + } + } + + /// A key written twice in one file keeps only one of its values without a + /// word: every key appears once (one per line). + #[test] + fn no_key_is_written_twice() { + for (json, locale) in [(EN, Locale::En), (FR, Locale::Fr)] { + let lines = json + .lines() + .filter(|line| line.trim_start().starts_with('"')) + .count(); + assert_eq!(lines, table(locale).len(), "{}", locale.as_str()); + } + } + + /// The shared labels (theme and accent names, the Preferences skeleton) + /// come from colony-ui only: a copy here would drift from them. + #[test] + fn no_key_shadows_a_shared_label() { + for key in table(Locale::En).keys() { + assert_eq!(shared::t_in(Locale::En, key), key, "{key} is shared"); + } + } + + /// Every `t("...")`, `tf("...")` and `tn("...")` in the sources names a key that + /// exists, and every key is used somewhere. The tests never switch the + /// locale: it is process-wide, and other tests read English strings. + #[test] + fn every_key_used_exists_and_every_key_is_used() { + let mut sources = String::new(); + let mut dirs = vec![std::path::PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src" + ))]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + // This module's own tests name keys that do not exist. + if path.is_dir() && !path.ends_with("i18n") { + dirs.push(path); + } else if path.extension().is_some_and(|e| e == "rs") { + sources.push_str(&std::fs::read_to_string(path).unwrap()); + } + } + } + + let src = sources.as_str(); + // The keys passed as the first argument of `name(...)`, however the + // call is wrapped. + let calls = |name: &str| -> Vec<&str> { + let call = format!("{name}("); + src.match_indices(&call) + .filter(|(at, _)| !src[..*at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) + .filter_map(|(at, _)| { + let rest = src[at + call.len()..].trim_start().strip_prefix('"')?; + Some(&rest[..rest.find('"')?]) + }) + .collect() + }; + let plurals: BTreeSet<&str> = calls("tn").into_iter().collect(); + for key in calls("t").into_iter().chain(calls("tf")) { + assert_ne!(t(key), key, "{key} is used but missing"); + } + for key in &plurals { + for form in ["one", "other"] { + let key = format!("{key}_{form}"); + assert_ne!(t(&key), key, "{key} is used but missing"); + } + } + for key in table(Locale::En).keys() { + let plural = ["_one", "_other"] + .iter() + .find_map(|form| key.strip_suffix(form)) + .is_some_and(|base| plurals.contains(base)); + assert!( + plural || src.contains(&format!("\"{key}\"")), + "{key} is unused" + ); + } + } + + #[test] + fn a_shared_label_reads_through_t_and_placeholders_are_filled() { + assert_eq!(t("settings_title"), "Preferences"); + assert_eq!(t("no_such_key"), "no_such_key"); + assert_eq!( + tf("quick_stat", &[("name", &"Playing"), ("count", &3)]), + t("quick_stat") + .replace("{name}", "Playing") + .replace("{count}", "3") + ); + assert_eq!(tn("games_count", 1, &[]), "1 game"); + assert_eq!(tn("games_count", 0, &[]), "0 games"); + } +} diff --git a/src/images.rs b/src/images.rs index 9cbdd2e..de97360 100644 --- a/src/images.rs +++ b/src/images.rs @@ -1,12 +1,13 @@ -use crate::db; use std::io::Read; use std::path::PathBuf; /// Maximum size for downloaded images (10 MB). const MAX_IMAGE_SIZE: u64 = 10_000_000; -pub fn cover_path(game_title: &str) -> PathBuf { - db::covers_dir().join(cover_file_name(game_title)) +/// Where a game's cover is cached, or `None` without a cache folder. +pub fn cover_path(game_title: &str) -> Option { + let layout = crate::paths::layout().ok()?; + Some(layout.covers.join(cover_file_name(game_title))) } /// File name of a game's cached cover, derived from its title. @@ -58,7 +59,7 @@ pub fn download_cover(url: &str, game_title: &str) -> Result { return Err("No cover URL".into()); } - let path = cover_path(game_title); + let path = cover_path(game_title).ok_or("No cache folder")?; if path.exists() { return Ok(path); } @@ -112,20 +113,9 @@ fn is_valid_image(data: &[u8]) -> bool { // ───── Achievement icons ───── -pub fn achievement_icons_dir() -> PathBuf { - let dir = db::cache_dir().join("achievement_icons"); - if let Err(e) = std::fs::create_dir_all(&dir) { - eprintln!( - "[images] Failed to create achievement icons dir {:?}: {}", - dir, e - ); - } - dir -} - -/// Local path for a cached achievement icon. +/// Local path for a cached achievement icon, or `None` without a cache folder. /// `gray` selects between the unlocked (colored) and locked (grayscale) variant. -pub fn achievement_icon_path(appid: u32, api_name: &str, gray: bool) -> PathBuf { +pub fn achievement_icon_path(appid: u32, api_name: &str, gray: bool) -> Option { let safe_name: String = api_name .chars() .map(|c| { @@ -138,7 +128,12 @@ pub fn achievement_icon_path(appid: u32, api_name: &str, gray: bool) -> PathBuf .collect::() .to_lowercase(); let suffix = if gray { "_gray" } else { "" }; - achievement_icons_dir().join(format!("{}_{}{}.jpg", appid, safe_name, suffix)) + let layout = crate::paths::layout().ok()?; + Some( + layout + .achievement_icons + .join(format!("{}_{}{}.jpg", appid, safe_name, suffix)), + ) } /// Download a single achievement icon if not already cached. @@ -171,12 +166,15 @@ pub fn download_achievement_icons(appid: u32, achievements: &[crate::models::Ach for ach in achievements { // Download the appropriate icon: colored for unlocked, gray for locked // We download both variants so switching states later doesn't require re-download - let colored_path = achievement_icon_path(appid, &ach.api_name, false); + let Some(colored_path) = achievement_icon_path(appid, &ach.api_name, false) else { + break; + }; if download_icon(&ach.icon_url, &colored_path).is_ok() { count += 1; } - let gray_path = achievement_icon_path(appid, &ach.api_name, true); - let _ = download_icon(&ach.icon_gray_url, &gray_path); + if let Some(gray_path) = achievement_icon_path(appid, &ach.api_name, true) { + let _ = download_icon(&ach.icon_gray_url, &gray_path); + } } count } @@ -187,12 +185,15 @@ pub fn download_achievement_icons(appid: u32, achievements: &[crate::models::Ach pub fn download_achievement_icons_minimal(appid: u32, icons: &[(String, String, String)]) -> u32 { let mut count = 0u32; for (api_name, icon_url, icon_gray_url) in icons { - let colored_path = achievement_icon_path(appid, api_name, false); + let Some(colored_path) = achievement_icon_path(appid, api_name, false) else { + break; + }; if download_icon(icon_url, &colored_path).is_ok() { count += 1; } - let gray_path = achievement_icon_path(appid, api_name, true); - let _ = download_icon(icon_gray_url, &gray_path); + if let Some(gray_path) = achievement_icon_path(appid, api_name, true) { + let _ = download_icon(icon_gray_url, &gray_path); + } } count } diff --git a/src/keyring.rs b/src/keyring.rs index bd150ca..13e895f 100644 --- a/src/keyring.rs +++ b/src/keyring.rs @@ -5,12 +5,15 @@ //! - macOS: Keychain //! - Linux: Secret Service (GNOME Keyring, KWallet, KeePassXC...) //! -//! Each secret is stored under the service name "spotter" with a per-field key, -//! and its database column is left empty. When no keyring is usable, the -//! secrets stay in the database, unencrypted, so they are never lost; the -//! Profile page says so. +//! Each secret is stored under the service name "spotter" with a per-field +//! key. When no keyring is usable, the secrets go to a file only the user can +//! read (`paths::Layout::credentials`) instead, unencrypted, so they are never +//! lost; the Profile page says so. Spotter 0.3 and earlier kept that fallback +//! in the database: those columns are emptied once the secrets are safe in the +//! keyring or the file. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, PoisonError}; @@ -24,7 +27,7 @@ use crate::models::UserProfile; const SERVICE: &str = "spotter"; /// Set on the first keyring failure. For the rest of the session Spotter -/// leaves the keyring alone: secrets are saved to the database, and nothing +/// leaves the keyring alone: secrets are saved to the file, and nothing /// is deleted from a keyring whose contents could not be read. static UNAVAILABLE: AtomicBool = AtomicBool::new(false); @@ -48,7 +51,7 @@ pub fn init() { } } -/// True when this session keeps the tokens unencrypted in the database. +/// True when this session keeps the tokens unencrypted in the file. pub fn is_unavailable() -> bool { UNAVAILABLE.load(Ordering::Relaxed) } @@ -56,7 +59,7 @@ pub fn is_unavailable() -> bool { fn unavailable(action: &str, e: impl std::fmt::Display) { if !UNAVAILABLE.swap(true, Ordering::Relaxed) { eprintln!( - "[keyring] Failed to {}: {}. Platform tokens are stored unencrypted in the database.", + "[keyring] Failed to {}: {}. Platform tokens are stored unencrypted in a file.", action, e ); } @@ -135,33 +138,79 @@ fn write(name: &'static str, secret: &str) -> bool { true } +/// The secrets in the fallback file, by keyring name. No file means none. +fn read_file(file: &Path) -> Result, SpotterError> { + match std::fs::read(file) { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()), + Err(e) => Err(e.into()), + } +} + +/// Make the fallback file hold exactly `secrets` (no file when there are +/// none), and only report success once reading it back returns them. +fn write_file(file: &Path, secrets: &BTreeMap) -> Result<(), SpotterError> { + if secrets.is_empty() { + return match std::fs::remove_file(file) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()), + _ => Ok(()), + }; + } + crate::paths::write_atomic(file, &serde_json::to_vec_pretty(secrets)?, true)?; + if read_file(file)? != *secrets { + return Err(SpotterError::Other("the file read back differently".into())); + } + Ok(()) +} + /// Load the profile with its secrets from the keyring. Secrets still in the -/// database (saved by Spotter 0.2 or earlier, or while no keyring was usable) -/// are newer than the keyring's, and are moved into it first. -pub fn load_profile(conn: &Connection) -> Result { +/// database (Spotter 0.3 and earlier) or in the file (saved while no keyring +/// was usable) are newer than the keyring's, and are moved into it first. +pub fn load_profile(conn: &Connection, file: &Path) -> Result { let mut profile = db::load_profile(conn)?; + let stored = read_file(file)?; let mut in_db = false; for (name, value) in secrets(&mut profile) { if !value.is_empty() { in_db = true; + } else if let Some(secret) = stored.get(name) { + value.clone_from(secret); } else if let Some(secret) = read(name) { *value = secret; } } - if in_db { - save_profile(conn, &profile)?; + if in_db || (!stored.is_empty() && !is_unavailable()) { + save_profile(conn, file, &profile)?; } Ok(profile) } /// Save the profile with its secrets in the keyring and empty in the database. -/// A secret the keyring did not take is kept in the database instead. -pub fn save_profile(conn: &Connection, profile: &UserProfile) -> Result<(), SpotterError> { +/// A secret the keyring did not take goes to the file instead, and stays in +/// the database only if the file cannot be written. +pub fn save_profile( + conn: &Connection, + file: &Path, + profile: &UserProfile, +) -> Result<(), SpotterError> { let _saving = SAVING.lock().unwrap_or_else(PoisonError::into_inner); let mut row = profile.clone(); + let mut kept = BTreeMap::new(); for (name, value) in secrets(&mut row) { - if write(name, value) { - value.clear(); + let secret = std::mem::take(value); + if !write(name, &secret) && !secret.is_empty() { + kept.insert(name.to_string(), secret); + } + } + if let Err(e) = write_file(file, &kept) { + eprintln!( + "[keyring] Failed to save the tokens to {:?}: {}. They stay in the database.", + file, e + ); + for (name, value) in secrets(&mut row) { + if let Some(secret) = kept.remove(name) { + *value = secret; + } } } db::save_profile(conn, &row) @@ -173,13 +222,15 @@ mod tests { use keyring_core::mock; #[test] - fn secrets_move_to_the_keyring_or_stay_in_the_database() { + fn secrets_move_to_the_keyring_or_to_the_file() { let dir = std::env::temp_dir().join(format!("spotter_keyring_test_{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("tokens.json"); let conn = db::open_at(&dir.join("spotter.db")).unwrap(); let in_db = |conn: &Connection| db::load_profile(conn).unwrap(); + let in_file = |name: &str| read_file(&file).unwrap().get(name).cloned(); - // What Spotter 0.2 and earlier left in the database. + // What Spotter 0.3 and earlier left in the database. let legacy = UserProfile { steam_api_key: "steam-key".into(), gog_refresh_token: "gog-refresh".into(), @@ -187,29 +238,52 @@ mod tests { }; db::save_profile(&conn, &legacy).unwrap(); - // No keyring: the secrets stay in the database. + // No keyring: the secrets move to the file, readable by the user only. keyring_core::unset_default_store(); - let p = load_profile(&conn).unwrap(); + let p = load_profile(&conn, &file).unwrap(); assert_eq!(p.steam_api_key, "steam-key"); - assert_eq!(in_db(&conn).steam_api_key, "steam-key"); + assert_eq!(in_file("steam_api_key").as_deref(), Some("steam-key")); + assert_eq!(in_db(&conn).steam_api_key, ""); assert!(is_unavailable()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&file).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // A file that cannot be written: the secret stays in the database. + let unwritable = dir.join("missing").join("tokens.json"); + let p2 = UserProfile { + psn_npsso: "psn-old".into(), + ..p.clone() + }; + save_profile(&conn, &unwritable, &p2).unwrap(); + assert_eq!(in_db(&conn).psn_npsso, "psn-old"); + // The next load puts it in a file that works. + let p = load_profile(&conn, &file).unwrap(); + assert_eq!(p.psn_npsso, "psn-old"); + assert_eq!(in_file("psn_npsso").as_deref(), Some("psn-old")); + assert_eq!(in_db(&conn).psn_npsso, ""); - // A keyring: the next start moves them there and empties the columns. + // A keyring: the next start moves the secrets there and removes the file. UNAVAILABLE.store(false, Ordering::Relaxed); keyring_core::set_default_store(mock::Store::new().unwrap()); - let mut p = load_profile(&conn).unwrap(); + let mut p = load_profile(&conn, &file).unwrap(); assert_eq!(p.steam_api_key, "steam-key"); assert_eq!(p.gog_refresh_token, "gog-refresh"); + assert!(!file.exists()); assert_eq!(in_db(&conn).steam_api_key, ""); - assert_eq!(in_db(&conn).gog_refresh_token, ""); assert!(!is_unavailable()); // Saving writes the keyring only; an emptied secret is deleted. p.steam_api_key.clear(); + p.psn_npsso.clear(); p.xbox_api_key = "xbox-key".into(); - save_profile(&conn, &p).unwrap(); + save_profile(&conn, &file, &p).unwrap(); assert_eq!(in_db(&conn).xbox_api_key, ""); - let p = load_profile(&conn).unwrap(); + assert!(!file.exists()); + let p = load_profile(&conn, &file).unwrap(); assert_eq!(p.steam_api_key, ""); assert_eq!(p.xbox_api_key, "xbox-key"); assert_eq!(p.gog_refresh_token, "gog-refresh"); @@ -220,9 +294,9 @@ mod tests { let gog = Entry::new(SERVICE, "gog_refresh_token").unwrap(); let cred: &mock::Cred = gog.as_any().downcast_ref().unwrap(); cred.set_error(Error::NoEntry); - let p = load_profile(&conn).unwrap(); + let p = load_profile(&conn, &file).unwrap(); assert_eq!(p.gog_refresh_token, ""); - save_profile(&conn, &p).unwrap(); + save_profile(&conn, &file, &p).unwrap(); assert_eq!(gog.get_secret().unwrap(), b"gog-refresh"); assert!(!is_unavailable()); @@ -230,14 +304,14 @@ mod tests { // for a broken keyring. std::thread::scope(|s| { for t in 0..8 { - let (conn, p) = (db::open_at(&dir.join("spotter.db")).unwrap(), &p); + let (conn, p, file) = (db::open_at(&dir.join("spotter.db")).unwrap(), &p, &file); s.spawn(move || { for i in 0..200 { let p = UserProfile { steam_api_key: format!("steam-{}-{}", t, i), ..p.clone() }; - save_profile(&conn, &p).unwrap(); + save_profile(&conn, file, &p).unwrap(); } }); } @@ -246,16 +320,17 @@ mod tests { assert_eq!(in_db(&conn).steam_api_key, ""); // A keyring that fails to read: nothing is deleted from it, and - // secrets saved afterwards stay in the database. + // secrets saved afterwards go to the file. let xbox = Entry::new(SERVICE, "xbox_api_key").unwrap(); let cred: &mock::Cred = xbox.as_any().downcast_ref().unwrap(); cred.set_error(Error::NoStorageAccess("locked".into())); - let mut p = load_profile(&conn).unwrap(); + let mut p = load_profile(&conn, &file).unwrap(); assert_eq!(p.xbox_api_key, ""); assert!(is_unavailable()); p.psn_npsso = "psn-token".into(); - save_profile(&conn, &p).unwrap(); - assert_eq!(in_db(&conn).psn_npsso, "psn-token"); + save_profile(&conn, &file, &p).unwrap(); + assert_eq!(in_file("psn_npsso").as_deref(), Some("psn-token")); + assert_eq!(in_db(&conn).psn_npsso, ""); assert_eq!(xbox.get_secret().unwrap(), b"xbox-key"); drop(conn); diff --git a/src/lib.rs b/src/lib.rs index f55115b..a77e8e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,12 +5,15 @@ pub mod db; pub mod error; pub mod keyring; pub mod models; +pub mod paths; +pub mod preferences; pub mod theme; mod app; mod epic; mod gog; mod handlers; +mod i18n; mod images; mod messages; mod playstation; diff --git a/src/messages/mod.rs b/src/messages/mod.rs index 847d9b3..1febc5d 100644 --- a/src/messages/mod.rs +++ b/src/messages/mod.rs @@ -1,5 +1,5 @@ mod auth; -mod settings; +mod preferences; pub use auth::AuthMessage; -pub use settings::SettingsMessage; +pub use preferences::PreferencesMessage; diff --git a/src/messages/preferences.rs b/src/messages/preferences.rs new file mode 100644 index 0000000..b6133c1 --- /dev/null +++ b/src/messages/preferences.rs @@ -0,0 +1,36 @@ +use colony_ui::preferences::Change; + +use crate::models::*; + +/// What the Preferences page sends. Every change is saved as it is made. +#[derive(Debug, Clone)] +pub enum PreferencesMessage { + /// Open the page, or close it and go back to the screen it was opened + /// from. The identity button and the page's Close button both send it. + Toggle, + /// Show a category, by its index in the category list. + Category(usize), + /// Open or close a collapsible section, by its key. + ToggleSection(&'static str), + /// A change to the preferences every Colony program shares. + Standard(Change), + Language(Option), + DefaultStatus(GameStatus), + DefaultPlatform(Platform), + ConfirmDelete, + StartScreen(StartScreen), + CompactList, + ShowCovers, + ShowStatusLabels, + LargeTargets, + SidebarWidth(u16), + Notifications, + ToastDuration(ToastDuration), + DateFormat(DateFormat), + DefaultSortOrder(SortOrder), + RememberFilters, + ShowDescriptions, + AchievementsDisplay(AchievementsDisplay), + DownloadCoversAuto, + Saved(Result<(), String>), +} diff --git a/src/messages/settings.rs b/src/messages/settings.rs deleted file mode 100644 index 95eeb22..0000000 --- a/src/messages/settings.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::models::*; - -#[derive(Debug, Clone)] -pub enum SettingsMessage { - ChangeSection(SettingsSection), - DefaultStatus(GameStatus), - DefaultPlatform(Platform), - ConfirmDelete, - StartScreen(StartScreen), - ThemeMode(ThemeMode), - AccentColor(AccentColor), - CompactList, - ShowCovers, - UiScale(UiScale), - HighContrast, - ShowStatusLabels, - LargeTargets, - SidebarWidth(u16), - Notifications, - ToastDuration(ToastDuration), - DateFormat(DateFormat), - DefaultSortOrder(SortOrder), - RememberFilters, - ShowDescriptions, - AchievementsDisplay(AchievementsDisplay), - DownloadCoversAuto, - Saved(Result<(), String>), - SaveShown, -} diff --git a/src/models.rs b/src/models.rs index adb09ea..749a673 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,9 @@ +use colony_ui::preferences::StandardPreferences; use serde::{Deserialize, Serialize}; use std::fmt; +use crate::i18n::{t, tf}; + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Game { pub id: Option, @@ -76,14 +79,34 @@ impl Platform { &Self::ALL } + /// The platform's identity colour: the shared accent closest to its brand. + /// Not `colony_ui::app_tint`, whose name hash gives Steam and PlayStation + /// the same red, and Epic and Nintendo the same indigo. pub fn color(&self) -> iced::Color { + crate::theme::shared_accent(self.accent_key()) + } + + /// The shared Colony accent that identifies this platform. + pub fn accent_key(&self) -> &'static str { match self { - Platform::Steam => iced::Color::from_rgb(0.4, 0.6, 0.9), - Platform::Gog => iced::Color::from_rgb(0.7, 0.5, 0.9), - Platform::Epic => iced::Color::from_rgb(0.9, 0.9, 0.3), - Platform::PlayStation => iced::Color::from_rgb(0.2, 0.4, 0.9), - Platform::Xbox => iced::Color::from_rgb(0.2, 0.8, 0.2), - Platform::Nintendo => iced::Color::from_rgb(0.9, 0.2, 0.2), + Platform::Steam => "blue", + Platform::Gog => "violet", + Platform::Epic => "yellow", + Platform::PlayStation => "indigo", + Platform::Xbox => "green", + Platform::Nintendo => "red", + } + } + + /// The store's full name, for headings. A brand, so never translated. + pub fn store_name(&self) -> &'static str { + match self { + Platform::Steam => "Steam", + Platform::Gog => "GOG Galaxy", + Platform::Epic => "Epic Games", + Platform::PlayStation => "PlayStation", + Platform::Xbox => "Xbox", + Platform::Nintendo => "Nintendo", } } @@ -139,6 +162,18 @@ impl fmt::Display for GameStatus { } impl GameStatus { + /// The status's name in the interface language. `Display` gives the name + /// the database stores, which does not change with the language. + pub fn label(&self) -> &'static str { + t(match self { + GameStatus::Playing => "status_playing", + GameStatus::Completed => "status_completed", + GameStatus::Unplayed => "status_unplayed", + GameStatus::Dropped => "status_dropped", + GameStatus::Wishlist => "status_wishlist", + }) + } + pub const ALL: [GameStatus; 5] = [ GameStatus::Playing, GameStatus::Completed, @@ -151,13 +186,20 @@ impl GameStatus { &Self::ALL } + /// The status colour, a shared accent so that it reads the same on every + /// theme and no two statuses share it. pub fn color(&self) -> iced::Color { + crate::theme::shared_accent(self.accent_key()) + } + + /// The shared Colony accent that identifies this status. + pub fn accent_key(&self) -> &'static str { match self { - GameStatus::Playing => iced::Color::from_rgb(0.2, 0.8, 0.4), - GameStatus::Completed => iced::Color::from_rgb(0.3, 0.6, 1.0), - GameStatus::Unplayed => iced::Color::from_rgb(0.9, 0.7, 0.2), - GameStatus::Dropped => iced::Color::from_rgb(0.8, 0.3, 0.3), - GameStatus::Wishlist => iced::Color::from_rgb(0.7, 0.5, 0.9), + GameStatus::Playing => "green", + GameStatus::Completed => "blue", + GameStatus::Unplayed => "amber", + GameStatus::Dropped => "red", + GameStatus::Wishlist => "violet", } } @@ -231,20 +273,19 @@ pub enum SortOrder { FavoritesFirst, } -impl fmt::Display for SortOrder { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SortOrder::TitleAsc => write!(f, "A-Z"), - SortOrder::TitleDesc => write!(f, "Z-A"), - SortOrder::PlaytimeDesc => write!(f, "Playtime"), - SortOrder::RatingDesc => write!(f, "Rating"), - SortOrder::LastPlayedDesc => write!(f, "Recent"), - SortOrder::FavoritesFirst => write!(f, "Favorites"), - } +impl SortOrder { + /// The order's name in the interface language. + pub fn label(&self) -> &'static str { + t(match self { + SortOrder::TitleAsc => "sort_title_asc", + SortOrder::TitleDesc => "sort_title_desc", + SortOrder::PlaytimeDesc => "sort_playtime", + SortOrder::RatingDesc => "sort_rating", + SortOrder::LastPlayedDesc => "sort_recent", + SortOrder::FavoritesFirst => "sort_favorites", + }) } -} -impl SortOrder { pub const ALL: [SortOrder; 6] = [ SortOrder::TitleAsc, SortOrder::TitleDesc, @@ -259,15 +300,18 @@ impl SortOrder { } } -/// Format a minute count as a human-readable string (e.g. "123h 45m" or "3.5h"). +/// Format a minute count for the interface ("123h 05m", "45m" in English). /// Shared across stats, profile, and detail views. pub fn format_playtime(minutes: u32) -> String { let hours = minutes / 60; let mins = minutes % 60; if hours > 0 { - format!("{}h {:02}m", hours, mins) + tf( + "playtime_hours", + &[("hours", &hours), ("minutes", &format!("{mins:02}"))], + ) } else { - format!("{}m", mins) + tf("playtime_minutes", &[("minutes", &mins)]) } } @@ -293,167 +337,7 @@ impl Default for UserProfile { } } -// ───── Settings ───── - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ThemeMode { - Dark, - Darker, - Midnight, -} - -impl fmt::Display for ThemeMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ThemeMode::Dark => write!(f, "Dark"), - ThemeMode::Darker => write!(f, "Darker"), - ThemeMode::Midnight => write!(f, "Midnight"), - } - } -} - -impl ThemeMode { - pub const ALL: [ThemeMode; 3] = [ThemeMode::Dark, ThemeMode::Darker, ThemeMode::Midnight]; - - pub fn all() -> &'static [ThemeMode] { - &Self::ALL - } - - /// Background color for main content area. - pub fn bg_main(&self) -> iced::Color { - match self { - ThemeMode::Dark => iced::Color::from_rgb(0.1, 0.1, 0.14), - ThemeMode::Darker => iced::Color::from_rgb(0.06, 0.06, 0.09), - ThemeMode::Midnight => iced::Color::from_rgb(0.02, 0.02, 0.05), - } - } - - /// Background color for sidebar. - pub fn bg_sidebar(&self) -> iced::Color { - match self { - ThemeMode::Dark => iced::Color::from_rgb(0.08, 0.08, 0.12), - ThemeMode::Darker => iced::Color::from_rgb(0.04, 0.04, 0.07), - ThemeMode::Midnight => iced::Color::from_rgb(0.01, 0.01, 0.03), - } - } - - /// Background color for cards/containers. - pub fn bg_card(&self) -> iced::Color { - match self { - ThemeMode::Dark => iced::Color::from_rgb(0.12, 0.12, 0.16), - ThemeMode::Darker => iced::Color::from_rgb(0.08, 0.08, 0.11), - ThemeMode::Midnight => iced::Color::from_rgb(0.04, 0.04, 0.07), - } - } - - /// Slightly darker row background inside cards (e.g. settings rows). - pub fn bg_row(&self) -> iced::Color { - match self { - ThemeMode::Dark => iced::Color::from_rgb(0.09, 0.09, 0.13), - ThemeMode::Darker => iced::Color::from_rgb(0.06, 0.06, 0.09), - ThemeMode::Midnight => iced::Color::from_rgb(0.03, 0.03, 0.05), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AccentColor { - Blue, - Purple, - Green, - Gold, - Red, - Cyan, -} - -impl fmt::Display for AccentColor { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AccentColor::Blue => write!(f, "Blue"), - AccentColor::Purple => write!(f, "Purple"), - AccentColor::Green => write!(f, "Green"), - AccentColor::Gold => write!(f, "Gold"), - AccentColor::Red => write!(f, "Red"), - AccentColor::Cyan => write!(f, "Cyan"), - } - } -} - -impl AccentColor { - pub const ALL: [AccentColor; 6] = [ - AccentColor::Blue, - AccentColor::Purple, - AccentColor::Green, - AccentColor::Gold, - AccentColor::Red, - AccentColor::Cyan, - ]; - - pub fn all() -> &'static [AccentColor] { - &Self::ALL - } - - pub fn color(&self) -> iced::Color { - match self { - AccentColor::Blue => iced::Color::from_rgb(0.6, 0.8, 1.0), - AccentColor::Purple => iced::Color::from_rgb(0.7, 0.5, 0.9), - AccentColor::Green => iced::Color::from_rgb(0.4, 0.9, 0.5), - AccentColor::Gold => iced::Color::from_rgb(1.0, 0.85, 0.3), - AccentColor::Red => iced::Color::from_rgb(1.0, 0.45, 0.45), - AccentColor::Cyan => iced::Color::from_rgb(0.3, 0.9, 0.9), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum UiScale { - Small, - Normal, - Large, -} - -impl fmt::Display for UiScale { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - UiScale::Small => write!(f, "Small"), - UiScale::Normal => write!(f, "Normal"), - UiScale::Large => write!(f, "Large"), - } - } -} - -impl UiScale { - pub const ALL: [UiScale; 3] = [UiScale::Small, UiScale::Normal, UiScale::Large]; - - pub fn all() -> &'static [UiScale] { - &Self::ALL - } - - pub fn factor(&self) -> f32 { - match self { - UiScale::Small => 0.85, - UiScale::Normal => 1.0, - UiScale::Large => 1.2, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SettingsSection { - General, - Appearances, - Accessibility, -} - -impl fmt::Display for SettingsSection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SettingsSection::General => write!(f, "General"), - SettingsSection::Appearances => write!(f, "Appearances"), - SettingsSection::Accessibility => write!(f, "Accessibility"), - } - } -} +// ───── Preferences ───── #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum DateFormat { @@ -463,17 +347,17 @@ pub enum DateFormat { Mdy, // 02/17/2026 } -impl fmt::Display for DateFormat { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - DateFormat::Ymd => write!(f, "YYYY-MM-DD"), - DateFormat::Dmy => write!(f, "DD/MM/YYYY"), - DateFormat::Mdy => write!(f, "MM/DD/YYYY"), - } +impl DateFormat { + /// The pattern as the user reads it, with the interface language's + /// letters for year, month and day. + pub fn label(&self) -> &'static str { + t(match self { + DateFormat::Ymd => "date_format_ymd", + DateFormat::Dmy => "date_format_dmy", + DateFormat::Mdy => "date_format_mdy", + }) } -} -impl DateFormat { pub const ALL: [DateFormat; 3] = [DateFormat::Ymd, DateFormat::Dmy, DateFormat::Mdy]; pub fn all() -> &'static [DateFormat] { @@ -520,17 +404,15 @@ pub enum ToastDuration { Long, // 8s } -impl fmt::Display for ToastDuration { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ToastDuration::Short => write!(f, "Short (2s)"), - ToastDuration::Normal => write!(f, "Normal (4s)"), - ToastDuration::Long => write!(f, "Long (8s)"), - } +impl ToastDuration { + pub fn label(&self) -> &'static str { + t(match self { + ToastDuration::Short => "toast_duration_short", + ToastDuration::Normal => "toast_duration_normal", + ToastDuration::Long => "toast_duration_long", + }) } -} -impl ToastDuration { pub const ALL: [ToastDuration; 3] = [ ToastDuration::Short, ToastDuration::Normal, @@ -558,17 +440,15 @@ pub enum AchievementsDisplay { Hidden, } -impl fmt::Display for AchievementsDisplay { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AchievementsDisplay::Full => write!(f, "Full"), - AchievementsDisplay::Summary => write!(f, "Summary"), - AchievementsDisplay::Hidden => write!(f, "Hidden"), - } +impl AchievementsDisplay { + pub fn label(&self) -> &'static str { + t(match self { + AchievementsDisplay::Full => "achievements_display_full", + AchievementsDisplay::Summary => "achievements_display_summary", + AchievementsDisplay::Hidden => "achievements_display_hidden", + }) } -} -impl AchievementsDisplay { pub const ALL: [AchievementsDisplay; 3] = [ AchievementsDisplay::Full, AchievementsDisplay::Summary, @@ -589,19 +469,18 @@ pub enum StartScreen { Profile, } -impl fmt::Display for StartScreen { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - StartScreen::Library => write!(f, "Library"), - StartScreen::Statistics => write!(f, "Statistics"), - StartScreen::Import => write!(f, "Import"), - StartScreen::Profile => write!(f, "Profile"), - } - } -} - #[allow(dead_code)] impl StartScreen { + /// The screen's name, as the sidebar shows it. + pub fn label(&self) -> &'static str { + t(match self { + StartScreen::Library => "nav_library", + StartScreen::Statistics => "nav_statistics", + StartScreen::Import => "nav_import", + StartScreen::Profile => "nav_profile", + }) + } + pub const ALL: [StartScreen; 4] = [ StartScreen::Library, StartScreen::Statistics, @@ -624,66 +503,54 @@ impl StartScreen { } } -/// Current settings schema version. -pub const SETTINGS_VERSION: u32 = 1; +/// Current settings schema version. 2: the settings moved from the database +/// to `preferences.json`, with the shared Colony preferences inside. +pub const PREFERENCES_VERSION: u32 = 2; -fn default_settings_version() -> u32 { - SETTINGS_VERSION -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Settings { +/// Everything the user chose. Saved to `preferences.json` (see +/// `crate::preferences`); a field missing from the file takes its default. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Preferences { /// Schema version for forward-compatible deserialization. - #[serde(default = "default_settings_version")] pub version: u32, + /// Theme, accent, text sizes and the accessibility switches every Colony + /// program shares, stored under the same names as theirs. + #[serde(flatten)] + pub standard: StandardPreferences, // General + /// The interface language ("en" or "fr"); none follows the system. + pub language: Option, pub default_status: GameStatus, pub default_platform: Platform, pub confirm_before_delete: bool, pub start_screen: StartScreen, - #[serde(default = "default_true")] pub notifications_enabled: bool, - #[serde(default)] pub toast_duration: ToastDuration, - #[serde(default)] pub date_format: DateFormat, - #[serde(default)] pub default_sort_order: SortOrder, - #[serde(default = "default_true")] pub remember_filters: bool, - #[serde(default)] pub last_filter_status: Option, - #[serde(default)] pub last_filter_platform: Option, - #[serde(default)] pub show_game_descriptions: bool, - #[serde(default)] pub achievements_display: AchievementsDisplay, - #[serde(default = "default_true")] pub download_covers_auto: bool, - #[serde(default)] pub favorites: Vec, - // Appearances - pub theme_mode: ThemeMode, - pub accent_color: AccentColor, + // Appearance pub compact_list: bool, pub show_covers_in_list: bool, - pub ui_scale: UiScale, pub sidebar_width: u16, // Accessibility - pub high_contrast: bool, pub show_status_labels: bool, pub large_click_targets: bool, } -fn default_true() -> bool { - true -} - -impl Default for Settings { +impl Default for Preferences { fn default() -> Self { Self { - version: SETTINGS_VERSION, + version: PREFERENCES_VERSION, + standard: StandardPreferences::default(), + language: None, default_status: GameStatus::Unplayed, default_platform: Platform::Steam, confirm_before_delete: true, @@ -699,19 +566,32 @@ impl Default for Settings { achievements_display: AchievementsDisplay::Full, download_covers_auto: true, favorites: Vec::new(), - theme_mode: ThemeMode::Dark, - accent_color: AccentColor::Blue, compact_list: false, show_covers_in_list: true, - ui_scale: UiScale::Normal, sidebar_width: 220, - high_contrast: false, show_status_labels: true, large_click_targets: false, } } } +impl Preferences { + /// Put the shared preferences and the interface language into effect. + pub fn apply(&self) { + self.standard.apply(); + crate::i18n::set_language(self.language.as_deref()); + } + + /// Vertical padding of buttons and chips, larger with "Large click targets". + pub fn button_padding(&self) -> u16 { + if self.large_click_targets { + 12 + } else { + 8 + } + } +} + /// Case-insensitive contains check. Uses fast ASCII path when possible, /// falls back to streaming char-by-char comparison for Unicode (no allocation). /// The `needle` MUST already be lowercase. diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..ffc1d78 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,406 @@ +//! Where Spotter keeps its files. +//! +//! The three roots come from `colony_ui::paths` (`Colony/Spotter/` under the +//! platform's config, data and cache directories), and each kind of file has +//! its own sub-directory, because config and data are the same directory on +//! Windows and macOS: +//! +//! | File | Where | +//! |---|---| +//! | Preferences | `/preferences/preferences.json` | +//! | Tokens, when no system keyring is usable | `/credentials/tokens.json` | +//! | Library database | `/library/spotter.db` | +//! | Exports and the automatic backup | `/exports/` | +//! | Import log | `/logs/` | +//! | Covers and achievement icons | `/covers/`, `/achievement_icons/` | +//! +//! Spotter 0.3 and earlier kept everything under the config root instead +//! (`data/spotter.db`, `exports/`, `logs/`, `cache/`). The first start copies +//! those files over, see [`migrate`]. + +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +const PROGRAM: &str = "Spotter"; +const DB_FILE: &str = "spotter.db"; +/// Written once the files of Spotter 0.3 and earlier were looked for, so the +/// copy runs at most once even if the new library is deleted later. +const MIGRATED_MARKER: &str = ".migrated-from-0.3"; + +/// Every path Spotter reads or writes. +#[derive(Debug, Clone, PartialEq)] +pub struct Layout { + pub db: PathBuf, + pub exports: PathBuf, + pub logs: PathBuf, + pub covers: PathBuf, + pub achievement_icons: PathBuf, + pub preferences: PathBuf, + pub credentials: PathBuf, +} + +static LAYOUT: OnceLock> = OnceLock::new(); + +/// Spotter's paths, resolved on first use: the directories are created, and +/// the files of an older Spotter are copied over first. +pub fn layout() -> Result<&'static Layout, String> { + LAYOUT + .get_or_init(|| { + let root = |dir: io::Result| { + dir.map_err(|e| format!("cannot use Spotter's folder: {}", e)) + }; + Layout::resolve( + &root(colony_ui::paths::config_dir(PROGRAM))?, + &root(colony_ui::paths::data_dir(PROGRAM))?, + &root(colony_ui::paths::cache_dir(PROGRAM))?, + ) + }) + .as_ref() + .map_err(Clone::clone) +} + +impl Layout { + /// The current layout under these roots. + fn current(config: &Path, data: &Path, cache: &Path) -> Self { + Self { + db: data.join("library").join(DB_FILE), + exports: data.join("exports"), + logs: data.join("logs"), + covers: cache.join("covers"), + achievement_icons: cache.join("achievement_icons"), + preferences: config.join("preferences").join("preferences.json"), + credentials: config.join("credentials").join("tokens.json"), + } + } + + /// Where Spotter 0.3 and earlier kept the library, its exports and its + /// log: under the config root, which `colony_ui::paths::config_dir` + /// resolves to the same directory those versions used on every platform. + /// Covers stay in the current cache: they are downloaded again. + fn legacy(config: &Path, data: &Path, cache: &Path) -> Self { + Self { + db: config.join("data").join(DB_FILE), + exports: config.join("exports"), + logs: config.join("logs"), + ..Self::current(config, data, cache) + } + } + + /// The layout to use under these roots, after copying the files of an + /// older Spotter. A copy that fails leaves those files where they are and + /// uses them from there, so a failed migration never shows an empty + /// library; the next start tries again. + fn resolve(config: &Path, data: &Path, cache: &Path) -> Result { + let current = Self::current(config, data, cache); + let layout = match migrate(&Self::legacy(config, data, cache), ¤t, data) { + Ok(()) => current, + Err(e) => { + eprintln!( + "[paths] Could not copy the files of an older Spotter ({}). Using them where they are.", + e + ); + Self::legacy(config, data, cache) + } + }; + layout + .create_dirs() + .map_err(|e| format!("cannot create Spotter's folders: {}", e))?; + Ok(layout) + } + + fn create_dirs(&self) -> io::Result<()> { + for dir in [ + &self.exports, + &self.logs, + &self.covers, + &self.achievement_icons, + ] { + fs::create_dir_all(dir)?; + } + for file in [&self.db, &self.preferences, &self.credentials] { + if let Some(dir) = file.parent() { + fs::create_dir_all(dir)?; + } + } + Ok(()) + } +} + +/// Copy the library, exports and log of Spotter 0.3 and earlier (`old`) to +/// the current layout (`new`), once. Nothing is moved or deleted: if this +/// release is wrong, the old files are still there. The library is copied +/// last, so the new library only exists once everything else is in place. +fn migrate(old: &Layout, new: &Layout, data: &Path) -> Result<(), String> { + let marker = data.join(MIGRATED_MARKER); + if marker.exists() { + return Ok(()); + } + if old.db.exists() && !new.db.exists() { + copy_new_files(&old.exports, &new.exports).map_err(|e| format!("exports: {}", e))?; + copy_new_files(&old.logs, &new.logs).map_err(|e| format!("logs: {}", e))?; + copy_db(&old.db, &new.db)?; + } + // Only an optimisation once the library is in place: without the marker, + // the next start finds the new library and copies nothing. + if let Err(e) = fs::write(&marker, "") { + eprintln!("[paths] Could not write {:?}: {}", marker, e); + } + Ok(()) +} + +/// Copy the files directly in `from` that `to` does not have yet. Nothing to +/// do when there is no `from`, or when both are the same directory (exports +/// and logs on Windows and macOS). +fn copy_new_files(from: &Path, to: &Path) -> io::Result<()> { + if from == to || !from.is_dir() { + return Ok(()); + } + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.file_type()?.is_file() && !target.exists() { + fs::copy(entry.path(), target)?; + } + } + Ok(()) +} + +/// Copy a database with SQLite itself rather than file by file: the copy is +/// one consistent file that includes what is still in the old one's +/// write-ahead log, and the old database is only read. +fn copy_db(from: &Path, to: &Path) -> Result<(), String> { + use rusqlite::{Connection, OpenFlags}; + + let dir = to.parent().ok_or("no library folder")?; + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + // A copy an interrupted start left behind: VACUUM INTO needs a new file. + let part = dir.join(format!("{}.part", DB_FILE)); + let _ = fs::remove_file(&part); + let part_name = part.to_str().ok_or("the library path is not valid UTF-8")?; + let conn = Connection::open_with_flags(from, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| e.to_string())?; + conn.execute("VACUUM INTO ?1", [part_name]) + .map_err(|e| e.to_string())?; + drop(conn); + fs::rename(&part, to).map_err(|e| e.to_string()) +} + +/// Replace `path` with `bytes` through a temporary file, so a crash never +/// leaves half a file. `private` makes it readable by the user only on Unix. +pub fn write_atomic(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> { + let tmp = path.with_extension("tmp"); + // A leftover would keep its permissions: start from a new file. + let _ = fs::remove_file(&tmp); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(not(unix))] + let _ = private; + let mut file = options.open(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&tmp, path) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + /// A fresh directory with config, data and cache roots inside. + struct Roots { + base: PathBuf, + config: PathBuf, + data: PathBuf, + cache: PathBuf, + } + + impl Roots { + fn new(name: &str) -> Self { + let base = + std::env::temp_dir().join(format!("spotter_paths_{}_{}", name, std::process::id())); + let _ = fs::remove_dir_all(&base); + let roots = Self { + config: base.join("config"), + data: base.join("data"), + cache: base.join("cache"), + base, + }; + for dir in [&roots.config, &roots.data, &roots.cache] { + fs::create_dir_all(dir).unwrap(); + } + roots + } + + fn resolve(&self) -> Layout { + Layout::resolve(&self.config, &self.data, &self.cache).unwrap() + } + + fn current(&self) -> Layout { + Layout::current(&self.config, &self.data, &self.cache) + } + + fn legacy(&self) -> Layout { + Layout::legacy(&self.config, &self.data, &self.cache) + } + + /// What Spotter 0.3 left: a library with one game in WAL mode, with + /// the game still in the write-ahead log, plus an export and a log. + /// The connection is returned so the log is not checkpointed away. + fn spotter_03(&self) -> Connection { + let old = self.legacy(); + fs::create_dir_all(old.db.parent().unwrap()).unwrap(); + let conn = Connection::open(&old.db).unwrap(); + conn.execute_batch( + "PRAGMA journal_mode = WAL; + CREATE TABLE games (title TEXT); + INSERT INTO games VALUES ('Outer Wilds');", + ) + .unwrap(); + fs::create_dir_all(&old.exports).unwrap(); + fs::write(old.exports.join("spotter_auto_backup.json"), "[]").unwrap(); + fs::create_dir_all(&old.logs).unwrap(); + fs::write(old.logs.join("import.log"), "old log").unwrap(); + conn + } + } + + impl Drop for Roots { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.base); + } + } + + fn titles(db: &Path) -> Vec { + let conn = Connection::open(db).unwrap(); + let mut stmt = conn.prepare("SELECT title FROM games").unwrap(); + let rows = stmt.query_map([], |r| r.get(0)).unwrap(); + rows.map(Result::unwrap).collect() + } + + #[test] + fn a_fresh_install_uses_the_current_layout() { + let roots = Roots::new("fresh"); + let layout = roots.resolve(); + assert_eq!(layout, roots.current()); + assert!(layout.db.parent().unwrap().is_dir()); + assert!(layout.preferences.parent().unwrap().is_dir()); + assert!(layout.covers.is_dir() && layout.logs.is_dir()); + assert!(roots.data.join(MIGRATED_MARKER).exists()); + assert!(!roots.legacy().db.exists(), "nothing to copy from"); + } + + #[test] + fn spotter_03_files_are_copied_and_left_in_place() { + let roots = Roots::new("legacy"); + let _open = roots.spotter_03(); + let old = roots.legacy(); + + let layout = roots.resolve(); + assert_eq!(layout, roots.current()); + assert_eq!(titles(&layout.db), ["Outer Wilds"]); + assert_eq!( + fs::read_to_string(layout.logs.join("import.log")).unwrap(), + "old log" + ); + assert!(layout.exports.join("spotter_auto_backup.json").exists()); + assert!(roots.data.join(MIGRATED_MARKER).exists()); + + // Copied, never moved. + assert_eq!(titles(&old.db), ["Outer Wilds"]); + assert!(old.logs.join("import.log").exists()); + assert!(old.exports.join("spotter_auto_backup.json").exists()); + } + + #[test] + fn a_migrated_install_is_not_copied_again() { + let roots = Roots::new("migrated"); + let _open = roots.spotter_03(); + let layout = roots.resolve(); + + // The user empties the new library; the old one must not come back, + // even without the marker as long as the new library exists. + Connection::open(&layout.db) + .unwrap() + .execute("DELETE FROM games", []) + .unwrap(); + assert_eq!(roots.resolve(), roots.current()); + assert!(titles(&layout.db).is_empty()); + + fs::remove_file(roots.data.join(MIGRATED_MARKER)).unwrap(); + assert_eq!(roots.resolve(), roots.current()); + assert!(titles(&layout.db).is_empty()); + + // With the marker, not even a deleted library brings it back. + fs::remove_file(&layout.db).unwrap(); + assert_eq!(roots.resolve(), roots.current()); + assert!(!layout.db.exists()); + } + + #[test] + fn a_failed_copy_keeps_using_the_old_files() { + let roots = Roots::new("failed"); + let _open = roots.spotter_03(); + // A file where the library folder should be: the copy cannot happen. + fs::write(roots.data.join("library"), "").unwrap(); + + let layout = roots.resolve(); + assert_eq!(layout.db, roots.legacy().db); + assert_eq!(layout.exports, roots.legacy().exports); + assert_eq!(titles(&layout.db), ["Outer Wilds"]); + assert!( + !roots.data.join(MIGRATED_MARKER).exists(), + "the next start tries again" + ); + + // Once the obstacle is gone, the next start copies. + fs::remove_file(roots.data.join("library")).unwrap(); + assert_eq!(roots.resolve(), roots.current()); + assert_eq!(titles(&roots.current().db), ["Outer Wilds"]); + } + + #[test] + fn same_directory_exports_are_left_alone() { + // Windows and macOS: config and data are one root, so the old and new + // exports folders are the same. + let dir = std::env::temp_dir().join(format!("spotter_same_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("a.json"), "a").unwrap(); + copy_new_files(&dir, &dir).unwrap(); + assert_eq!(fs::read_dir(&dir).unwrap().count(), 1); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn existing_files_are_not_overwritten() { + let roots = Roots::new("no_overwrite"); + let (from, to) = (roots.base.join("from"), roots.base.join("to")); + fs::create_dir_all(&from).unwrap(); + fs::create_dir_all(&to).unwrap(); + fs::write(from.join("x"), "old").unwrap(); + fs::write(to.join("x"), "new").unwrap(); + copy_new_files(&from, &to).unwrap(); + assert_eq!(fs::read_to_string(to.join("x")).unwrap(), "new"); + } + + #[test] + #[cfg(unix)] + fn private_files_are_readable_by_the_user_only() { + use std::os::unix::fs::PermissionsExt; + let roots = Roots::new("private"); + let path = roots.config.join("tokens.json"); + write_atomic(&path, b"{}", true).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + write_atomic(&path, b"{\"a\":\"b\"}", true).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "{\"a\":\"b\"}"); + } +} diff --git a/src/playstation.rs b/src/playstation.rs index bebc7d2..7d793bd 100644 --- a/src/playstation.rs +++ b/src/playstation.rs @@ -156,7 +156,7 @@ fn exchange_npsso(npsso: &str) -> Result { }; if code.is_empty() { - return Err("Empty authorization code. NPSSO token may be expired.".into()); + return Err(crate::i18n::t("error_psn_expired").into()); } eprintln!("[psn] Got authorization code, exchanging for access token..."); @@ -299,10 +299,10 @@ fn parse_play_duration(duration: &str) -> u32 { pub fn full_import(npsso: &str) -> Result, String> { let access_token = exchange_npsso(npsso)?; - let logger = crate::api_client::ImportLogger::new(&crate::api_client::log_dir()); + let logger = crate::api_client::ImportLogger::for_imports(); logger.log("psn", "Starting PlayStation import"); - // Fetch trophy titles (primary source — has achievement data) + // Fetch trophy titles (primary source: has achievement data) eprintln!("[psn] Fetching trophy titles..."); let trophy_titles = fetch_trophy_titles(&access_token)?; eprintln!("[psn] Found {} trophy titles", trophy_titles.len()); diff --git a/src/preferences.rs b/src/preferences.rs new file mode 100644 index 0000000..072b6b6 --- /dev/null +++ b/src/preferences.rs @@ -0,0 +1,283 @@ +//! The preferences file, `/preferences/preferences.json` (see +//! `crate::paths`): Spotter's [`Preferences`], with the shared Colony +//! preferences flattened in. + +use std::path::Path; + +use colony_ui::TextSize; +use rusqlite::Connection; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::db; +use crate::error::SpotterError; +use crate::models::{Preferences, PREFERENCES_VERSION}; + +/// Load the preferences. Without a preferences file, the settings an older +/// Spotter saved in the library database are imported and written to it +/// once; without those either, the defaults. +/// +/// A file that is not JSON at all is an error: the app then refuses to save +/// anything rather than replace it with the defaults. +pub fn load(path: &Path, conn: &Connection) -> Result { + match std::fs::read_to_string(path) { + Ok(json) => parse(&json), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let Some(row) = db::load_legacy_settings(conn)? else { + return Ok(Preferences::default()); + }; + let prefs = import_legacy(&row)?; + // The database row stays, so a failed write only means the + // import runs again next time. + if let Err(e) = save(path, &prefs) { + eprintln!("[preferences] Could not write {:?}: {}", path, e); + } + Ok(prefs) + } + Err(e) => Err(e.into()), + } +} + +pub fn save(path: &Path, prefs: &Preferences) -> Result<(), SpotterError> { + let json = serde_json::to_vec_pretty(prefs)?; + crate::paths::write_atomic(path, &json, false)?; + Ok(()) +} + +/// Parse saved preferences one field at a time: a field whose value this +/// build does not accept (for example a value added by a newer version) keeps +/// its default, so one bad value never discards the other preferences. +pub fn parse(json: &str) -> Result { + if let Ok(prefs) = serde_json::from_str(json) { + return Ok(prefs); + } + let stored: Map = serde_json::from_str(json)?; + let mut kept = Map::new(); + for (key, value) in stored { + kept.insert(key.clone(), value); + if Preferences::deserialize(&Value::Object(kept.clone())).is_err() { + eprintln!("[preferences] Ignoring invalid stored preference {:?}", key); + kept.remove(&key); + } + } + Ok(Preferences::deserialize(Value::Object(kept))?) +} + +/// Turn the settings row of Spotter 0.3 and earlier into preferences. The +/// fields keep their names, except the three that became shared Colony +/// preferences: Spotter's own themes, accents and interface scale map to the +/// closest shared theme, accent and text size. High contrast already has the +/// shared name. +fn import_legacy(json: &str) -> Result { + let mut prefs = parse(json)?; + prefs.version = PREFERENCES_VERSION; + let old: Value = serde_json::from_str(json)?; + let field = |key: &str| old.get(key).and_then(Value::as_str); + let standard = &mut prefs.standard; + if let Some((family, variant)) = field("theme_mode").and_then(legacy_theme) { + standard.selected_theme = family.into(); + standard.selected_variant = variant.into(); + } + standard.selected_accent = field("accent_color") + .and_then(legacy_accent) + .map(Into::into); + if let Some(size) = field("ui_scale").and_then(legacy_text_size) { + standard.text_size_a11y = size; + } + Ok(prefs) +} + +/// The shared theme closest to each of Spotter's old dark themes, by +/// background and accent. +fn legacy_theme(mode: &str) -> Option<(&'static str, &'static str)> { + Some(match mode { + "Dark" => ("tokyonight", "night"), + "Darker" => ("material", "deepocean"), + "Midnight" => ("modus", "vivendi"), + _ => return None, + }) +} + +/// The shared accent closest to each of Spotter's old accents. +fn legacy_accent(accent: &str) -> Option<&'static str> { + Some(match accent { + "Blue" | "Cyan" => "blue", + "Purple" => "violet", + "Green" => "green", + "Gold" => "amber", + "Red" => "red", + _ => return None, + }) +} + +/// The old interface scale (85, 100 or 120 %) is now the Accessibility text +/// size, which has the same steps. +fn legacy_text_size(scale: &str) -> Option { + Some(match scale { + "Small" => TextSize::Small, + "Normal" => TextSize::Default, + "Large" => TextSize::Large, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::StartScreen; + use colony_ui::preferences::StandardPreferences; + + struct Dir(std::path::PathBuf); + + impl Dir { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "spotter_preferences_{}_{}", + name, + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn db(&self) -> Connection { + db::open_at(&self.0.join("spotter.db")).unwrap() + } + + fn file(&self) -> std::path::PathBuf { + self.0.join("preferences.json") + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A settings row as Spotter 0.3 wrote it. + const ROW_03: &str = r#"{"version":1,"default_status":"Playing","default_platform":"GOG", + "confirm_before_delete":false,"start_screen":"Statistics","notifications_enabled":true, + "toast_duration":"Long","date_format":"Dmy","default_sort_order":"PlaytimeDesc", + "remember_filters":true,"last_filter_status":null,"last_filter_platform":"Steam", + "show_game_descriptions":true,"achievements_display":"Summary", + "download_covers_auto":false,"favorites":[3,7],"theme_mode":"Darker", + "accent_color":"Purple","compact_list":true,"show_covers_in_list":false, + "ui_scale":"Large","sidebar_width":280,"high_contrast":true, + "show_status_labels":false,"large_click_targets":true}"#; + + #[test] + fn the_settings_row_is_imported_into_preferences_json() { + let dir = Dir::new("import"); + let conn = dir.db(); + conn.execute("INSERT INTO settings (id, data) VALUES (1, ?1)", [ROW_03]) + .unwrap(); + + let loaded = load(&dir.file(), &conn).unwrap(); + assert_eq!(loaded.version, PREFERENCES_VERSION); + assert_eq!(loaded.start_screen, StartScreen::Statistics); + assert_eq!(loaded.favorites, [3, 7]); + assert_eq!(loaded.sidebar_width, 280); + assert!(loaded.compact_list && loaded.large_click_targets); + assert!(!loaded.show_status_labels && !loaded.download_covers_auto); + let standard = &loaded.standard; + assert_eq!( + ( + standard.selected_theme.as_str(), + standard.selected_variant.as_str() + ), + ("material", "deepocean") + ); + assert_eq!(standard.selected_accent.as_deref(), Some("violet")); + assert_eq!(standard.text_size_a11y, TextSize::Large); + assert!(standard.high_contrast); + + // Written once, and read back from the file from then on. + let written = std::fs::read_to_string(dir.file()).unwrap(); + assert!(!written.contains("theme_mode"), "{written}"); + assert_eq!(parse(&written).unwrap(), loaded); + conn.execute("DELETE FROM settings", []).unwrap(); + assert_eq!(load(&dir.file(), &conn).unwrap(), loaded); + // The old row was only read. + let other = dir.0.join("other.json"); + assert_eq!(load(&other, &conn).unwrap(), Preferences::default()); + } + + #[test] + fn every_legacy_theme_and_accent_maps_to_a_shipped_one() { + for mode in ["Dark", "Darker", "Midnight"] { + let (family, variant) = legacy_theme(mode).unwrap(); + let family = colony_ui::theme::family(family).expect(mode); + assert!(family.variant(variant).is_some(), "{mode}"); + } + for accent in ["Blue", "Purple", "Green", "Gold", "Red", "Cyan"] { + let key = legacy_accent(accent).unwrap(); + assert!(colony_ui::accent_key_to_color(key).is_some(), "{accent}"); + } + for (scale, factor) in [("Small", 0.85), ("Normal", 1.0), ("Large", 1.2)] { + assert_eq!(legacy_text_size(scale).unwrap().factor(), factor); + } + } + + #[test] + fn a_legacy_row_with_unknown_values_keeps_the_rest() { + let prefs = import_legacy( + r#"{"theme_mode":"Oled","accent_color":42,"ui_scale":"Huge", + "default_status":"Bogus","compact_list":true}"#, + ) + .unwrap(); + assert_eq!(prefs.standard, StandardPreferences::default()); + assert_eq!(prefs.default_status, Preferences::default().default_status); + assert!(prefs.compact_list); + } + + #[test] + fn a_fresh_install_has_the_defaults_and_writes_nothing() { + let dir = Dir::new("fresh"); + assert_eq!( + load(&dir.file(), &dir.db()).unwrap(), + Preferences::default() + ); + assert!(!dir.file().exists()); + } + + #[test] + fn preferences_round_trip() { + let dir = Dir::new("round_trip"); + let mut prefs = Preferences::default(); + prefs.standard.selected_theme = "nord".into(); + prefs.standard.selected_accent = Some("amber".into()); + prefs.compact_list = true; + save(&dir.file(), &prefs).unwrap(); + assert_eq!(load(&dir.file(), &dir.db()).unwrap(), prefs); + } + + #[test] + fn one_bad_value_keeps_the_other_preferences() { + let prefs = + parse(r#"{"sidebar_width":"wide","compact_list":true,"selected_accent":"green"}"#) + .unwrap(); + assert_eq!(prefs.sidebar_width, Preferences::default().sidebar_width); + assert!(prefs.compact_list); + assert_eq!(prefs.standard.selected_accent.as_deref(), Some("green")); + } + + #[test] + fn an_unknown_theme_still_loads_and_falls_back_to_the_default_palette() { + let prefs = + parse(r#"{"selected_theme":"removed_family","selected_variant":"dusk"}"#).unwrap(); + let standard = &prefs.standard; + assert_eq!( + colony_ui::resolve(&standard.selected_theme, &standard.selected_variant), + colony_ui::FALLBACK_PALETTE + ); + } + + #[test] + fn a_file_that_is_not_json_is_an_error() { + let dir = Dir::new("broken"); + std::fs::write(dir.file(), "not json").unwrap(); + assert!(load(&dir.file(), &dir.db()).is_err()); + } +} diff --git a/src/steam/auth.rs b/src/steam/auth.rs index 986551d..2f803e7 100644 --- a/src/steam/auth.rs +++ b/src/steam/auth.rs @@ -2,6 +2,11 @@ use std::io::{Read, Write}; use std::net::TcpListener; use std::time::Duration; +use colony_ui::theme::Palette; +use iced::Color; + +use crate::i18n::t; + const REDIRECT_PORTS: &[u16] = &[29876, 29877, 29878, 29879, 29880]; const STEAM_OPENID_URL: &str = "https://steamcommunity.com/openid/login"; @@ -63,6 +68,22 @@ fn extract_steam_id(query: &str) -> Option { None } +/// The page the browser shows once Steam hands the login back, in the colours +/// of the theme Spotter is using. +fn callback_page(title: &str, message: &str, ink: Color) -> String { + fn css(c: Color) -> String { + let [r, g, b, _] = c.into_rgba8(); + format!("#{r:02x}{g:02x}{b:02x}") + } + format!( + "\ +

{title}

{message}

", + colony_ui::i18n::locale().as_str(), + css(Palette::BG_PRIMARY()), + css(ink), + ) +} + fn url_decode(s: &str) -> String { crate::api_client::url_decode(s) } @@ -106,12 +127,12 @@ fn verify_openid_signature(query: &str) -> Result<(), String> { if resp_body.contains("is_valid:true") { Ok(()) } else { - Err("Steam OpenID signature verification failed. Login may have been tampered with.".into()) + Err(t("error_steam_signature").into()) } } /// Perform Steam OpenID login flow: -/// 1. Start a local HTTP server (tries ports 29876–29880) +/// 1. Start a local HTTP server (tries ports 29876-29880) /// 2. Open the Steam login page in the user's browser /// 3. Wait for the callback with the Steam ID /// 4. Return the Steam ID 64 @@ -151,7 +172,7 @@ pub fn steam_login() -> Result { loop { if start.elapsed() > timeout { - return Err("Steam login timed out (2 minutes). Please try again.".into()); + return Err(t("error_steam_timeout").into()); } match listener.accept() { @@ -173,10 +194,13 @@ pub fn steam_login() -> Result { // Verify OpenID signature before trusting the response if let Err(e) = verify_openid_signature(query) { eprintln!("[steam_auth] {}", e); - let html = "\ -

Login Failed

OpenID signature verification failed. Please try again.

"; + let html = callback_page( + t("steam_callback_failed_title"), + t("steam_callback_failed_text"), + Palette::ERROR(), + ); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", html.len(), html ); let _ = stream.write_all(response.as_bytes()); @@ -185,10 +209,13 @@ pub fn steam_login() -> Result { } // Send success response - let html = "\ -

Steam Login Successful!

Your Steam ID has been detected. You can close this tab and return to Spotter.

"; + let html = callback_page( + t("steam_callback_ok_title"), + t("steam_callback_ok_text"), + Palette::TEXT_PRIMARY(), + ); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", html.len(), html ); @@ -215,3 +242,17 @@ pub fn steam_login() -> Result { } } } + +#[cfg(test)] +mod tests { + #[test] + fn the_callback_page_is_drawn_in_the_given_colours() { + const INK: u32 = 0xabcdef; + let page = super::callback_page("Done", "Close this tab.", colony_ui::hex(INK)); + assert!(page.contains(&format!("color:#{INK:06x}")), "{page}"); + assert!( + page.contains("

Done

Close this tab.

"), + "{page}" + ); + } +} diff --git a/src/steam/mod.rs b/src/steam/mod.rs index c42cb99..4c3bcb9 100644 --- a/src/steam/mod.rs +++ b/src/steam/mod.rs @@ -57,7 +57,7 @@ fn make_agent() -> ureq::Agent { crate::api_client::api_agent() } -/// HTTP GET with one retry — used for the Steam Web API (owned games). +/// HTTP GET with one retry: used for the Steam Web API (owned games). fn http_get_with_retry(agent: &ureq::Agent, url: &str) -> Result { crate::api_client::http_get_retry(agent, url, 1) } @@ -65,7 +65,7 @@ fn http_get_with_retry(agent: &ureq::Agent, url: &str) -> Result // ───── HTML scraping helpers ───── /// Decode common HTML entities in scraped text (single pass, no intermediate Strings). -/// Used only in tests — scraper handles entity decoding automatically in production. +/// Used only in tests: scraper handles entity decoding automatically in production. #[allow(dead_code)] fn decode_html_entities(s: &str) -> String { if !s.contains('&') { @@ -382,7 +382,7 @@ pub fn scrape_achievements( let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; - // 1. Scrape global page — all achievements with colored icons + // 1. Scrape global page: all achievements with colored icons let global_url = format!("https://steamcommunity.com/stats/{}/achievements", appid); let global_html = match agent.get(&global_url).header("User-Agent", ua).call() { Ok(r) => r @@ -416,7 +416,7 @@ pub fn scrape_achievements( }); } - // 2. Scrape player page — unlock status, descriptions, unlock times + // 2. Scrape player page: unlock status, descriptions, unlock times let player_url = format!( "https://steamcommunity.com/profiles/{}/stats/appid/{}/achievements/", steam_id, appid @@ -433,7 +433,7 @@ pub fn scrape_achievements( parse_player_achievements(&html) } } - Err(_) => HashMap::new(), // Profile unavailable — treat all as locked + Err(_) => HashMap::new(), // Profile unavailable: treat all as locked }; // 3. Combine: global page provides the full list + icons, @@ -483,7 +483,7 @@ pub fn scrape_achievements( } /// Scrape a Steam store page to extract genre, tags, description, -/// release date, and review percentage — all in a single HTTP request. +/// release date, and review percentage: all in a single HTTP request. /// Sends cookies to bypass age gates for mature-rated games. fn scrape_store_page(agent: &ureq::Agent, appid: u32) -> Result { let url = format!("https://store.steampowered.com/app/{}/", appid); @@ -511,7 +511,7 @@ fn scrape_store_page(agent: &ureq::Agent, appid: u32) -> Result ResultAction + // Genres: from links like Action let genre_list = extract_anchors(&html, r#"a[href*="/genre/"]"#); if genre_list.is_empty() && !html.contains("game_area_genre") { eprintln!( @@ -530,7 +530,7 @@ fn scrape_store_page(agent: &ureq::Agent, appid: u32) -> ResultRPG + // User-defined tags: from RPG let tag_list = extract_anchors(&html, "a.app_tag"); if tag_list.is_empty() && !html.contains("app_tag") { eprintln!( @@ -540,7 +540,7 @@ fn scrape_store_page(agent: &ureq::Agent, appid: u32) -> Result, ) -> Result, String> { - let logger = crate::api_client::ImportLogger::new(&crate::api_client::log_dir()); + let logger = crate::api_client::ImportLogger::for_imports(); logger.log("steam", "Starting Steam full import"); eprintln!("[steam] Starting full import..."); @@ -621,7 +621,7 @@ pub fn full_import( let games = Arc::new(Mutex::new(games)); let progress = Arc::new(AtomicUsize::new(0)); let failed = Arc::new(Mutex::new(Vec::::new())); - // AtomicUsize counter replaces Mutex> queue — no lock contention + // AtomicUsize counter replaces Mutex> queue: no lock contention let next_idx = Arc::new(AtomicUsize::new(0)); let mut handles = Vec::new(); @@ -775,7 +775,7 @@ pub fn full_import( // Don't log every skipped game to avoid spam } else { // Read title from locked games only for logging (avoids cloning - // all titles upfront — saves ~150KB+ for large libraries) + // all titles upfront: saves ~150KB+ for large libraries) let title = games.lock().map(|g| g[i].title.clone()).unwrap_or_default(); if warnings.is_empty() { eprintln!("[steam] [{}/{}] {}", done, total, title); @@ -884,7 +884,7 @@ mod tests { #[test] fn extract_anchors_with_nested_tags() { - // scraper collects text recursively — nested elements are handled correctly. + // scraper collects text recursively: nested elements are handled correctly. let html = r#"Action"#; let genres = extract_anchors(html, r#"a[href*="/genre/"]"#); assert_eq!(genres, vec!["Action"]); diff --git a/src/theme.rs b/src/theme.rs index 7032c62..811bdca 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,16 +1,16 @@ -use iced::widget::{button, container}; -use iced::{Background, Border, Color, Font, Theme}; - -use crate::models::{DateFormat, Settings}; +//! Spotter's widget styles. Every colour comes from the active Colony palette +//! (`colony_ui::theme::Palette`) or the shared accent swatches: Spotter defines +//! none of its own, so a theme, accent or high-contrast change reaches every +//! widget. -// ── Font ── +use colony_ui::fonts::glyph_font; +use colony_ui::theme::Palette; +use colony_ui::{accent_key_to_color, contrast_on, sz, ui_font}; +use iced::widget::text::{IntoFragment, Rich, Span}; +use iced::widget::{button, container}; +use iced::{Background, Border, Color, Theme}; -/// Bold weight of the JetBrainsMono Nerd Font. -pub const FONT_BOLD: Font = Font { - family: iced::font::Family::Name("JetBrainsMono NF"), - weight: iced::font::Weight::Bold, - ..Font::DEFAULT -}; +use crate::app::Message; /// Nerd Font icon constants (Font Awesome + Material Design Icons). /// Some icons are kept as a reference catalog even if not currently used. @@ -52,160 +52,62 @@ pub mod icons { pub const CIRCLE_O: &str = "\u{f10c}"; // nf-fa-circle-o } -// Static color constants are used in style closures (button/container styles) -// where ViewTheme isn't available. ViewTheme provides theme-mode-aware colors -// for view layout code. These are complementary, not redundant. - -// ── Backgrounds ── -pub const BG_DARK: Color = Color { - r: 0.09, - g: 0.09, - b: 0.13, - a: 1.0, -}; -pub const BG_DARKER: Color = Color { - r: 0.04, - g: 0.04, - b: 0.08, - a: 1.0, -}; -pub const BG_BUTTON: Color = Color { - r: 0.11, - g: 0.11, - b: 0.16, - a: 1.0, -}; - -// ── Text (contrast-improved for WCAG readability) ── -pub const TEXT_MUTED: Color = Color { - r: 0.5, - g: 0.5, - b: 0.6, - a: 1.0, -}; -pub const TEXT_DIM: Color = Color { - r: 0.42, - g: 0.42, - b: 0.52, - a: 1.0, -}; -pub const TEXT_SECONDARY: Color = Color { - r: 0.62, - g: 0.62, - b: 0.72, - a: 1.0, -}; -pub const TEXT_LIGHT: Color = Color { - r: 0.75, - g: 0.75, - b: 0.85, - a: 1.0, -}; -pub const TEXT_HEADING: Color = Color { - r: 0.3, - g: 0.3, - b: 0.4, - a: 1.0, -}; +/// Interface text at `sz(base)`: both text-size preferences (Appearance and +/// Accessibility) scale it, and it is set in the interface font, which turns +/// into OpenDyslexic with the dyslexia toggle. The Nerd Font glyphs inside it +/// are set in the glyph font, since OpenDyslexic has none of them. +pub fn ui_text<'a>(content: impl IntoFragment<'a>, base: u16) -> Rich<'a, (), Message> { + let content = content.into_fragment(); + let spans: Vec> = glyph_runs(&content) + .map(|(run, glyph)| { + let span = Span::new(run.to_owned()); + if glyph { + span.font(glyph_font()) + } else { + span + } + }) + .collect(); + Rich::with_spans(spans).size(sz(base)).font(ui_font()) +} -// ── Accents ── -pub const ACCENT_BLUE: Color = Color { - r: 0.45, - g: 0.72, - b: 1.0, - a: 1.0, -}; -pub const ACCENT_GOLD: Color = Color { - r: 1.0, - g: 0.82, - b: 0.28, - a: 1.0, -}; -pub const SUCCESS: Color = Color { - r: 0.25, - g: 0.85, - b: 0.45, - a: 1.0, -}; -pub const COMPLETED_BLUE: Color = Color { - r: 0.35, - g: 0.62, - b: 1.0, - a: 1.0, -}; -pub const DANGER: Color = Color { - r: 1.0, - g: 0.38, - b: 0.38, - a: 1.0, -}; +/// Nerd Font glyphs live in the private use areas. +fn is_glyph(c: char) -> bool { + matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..='\u{FFFFD}') +} -// ── Borders ── -pub const BORDER: Color = Color { - r: 0.16, - g: 0.16, - b: 0.22, - a: 1.0, -}; -pub const BORDER_CARD: Color = Color { - r: 0.18, - g: 0.18, - b: 0.24, - a: 1.0, -}; +/// Split `s` into its runs of glyphs and of plain text, in order. +fn glyph_runs(s: &str) -> impl Iterator { + let mut rest = s; + std::iter::from_fn(move || { + let first = rest.chars().next()?; + let glyph = is_glyph(first); + let end = rest + .char_indices() + .find(|&(_, c)| is_glyph(c) != glyph) + .map_or(rest.len(), |(i, _)| i); + let (run, tail) = rest.split_at(end); + rest = tail; + Some((run, glyph)) + }) +} -/// Lighten a color by a fixed amount (clamped to 1.0). -pub fn lighten(color: Color, amount: f32) -> Color { - Color { - r: (color.r + amount).min(1.0), - g: (color.g + amount).min(1.0), - b: (color.b + amount).min(1.0), - a: color.a, - } +/// A shared Colony accent swatch by key (`colony_ui::ACCENT_OVERRIDES`): the +/// same colour on every theme, which is what a platform or a game status needs +/// to stay recognisable. Falls back to the theme's accent for an unknown key. +pub fn shared_accent(key: &str) -> Color { + accent_key_to_color(key).unwrap_or_else(Palette::ACCENT) } -/// Pre-computed theme values derived from user Settings. -/// Create with `ViewTheme::from_settings(&app.settings)` at the top of each view. -#[derive(Clone, Copy)] -#[allow(dead_code)] -pub struct ViewTheme { - pub bg_main: Color, - pub bg_card: Color, - pub bg_row: Color, - pub bg_sidebar: Color, - pub border: Color, - pub text_muted: Color, - pub text_dim: Color, - pub text_secondary: Color, - pub text_light: Color, - pub btn_padding: u16, - pub show_status_labels: bool, - pub date_format: DateFormat, +fn is_hovered(status: button::Status) -> bool { + matches!(status, button::Status::Hovered | button::Status::Pressed) } -impl ViewTheme { - pub fn from_settings(s: &Settings) -> Self { - let hc = s.high_contrast; - let lt = s.large_click_targets; - Self { - bg_main: s.theme_mode.bg_main(), - bg_card: s.theme_mode.bg_card(), - bg_row: s.theme_mode.bg_row(), - bg_sidebar: s.theme_mode.bg_sidebar(), - border: if hc { - Color::from_rgb(0.35, 0.35, 0.45) - } else { - BORDER - }, - text_muted: if hc { TEXT_SECONDARY } else { TEXT_MUTED }, - text_dim: if hc { TEXT_MUTED } else { TEXT_DIM }, - text_secondary: if hc { TEXT_LIGHT } else { TEXT_SECONDARY }, - text_light: if hc { Color::WHITE } else { TEXT_LIGHT }, - btn_padding: if lt { 12 } else { 8 }, - show_status_labels: s.show_status_labels, - date_format: s.date_format, - } - } +/// The outline of an inactive control. Drawn from the ink rather than from a +/// surface, so it shows on the page and on a card alike (several themes give +/// cards and borders the same colour). +fn control_outline() -> Color { + Palette::TEXT_DIMMEST().scale_alpha(0.4) } // ── Reusable style helpers (with hover support) ── @@ -227,38 +129,38 @@ pub fn card_style( } } -/// Button style for chip-like toggles (filter chips, option chips). +/// Chip-like toggle (filters, options, status and rating pickers): tinted with +/// `color` while active, outlined otherwise. pub fn chip_style( active: bool, - text_color: Color, - accent: Color, + color: Color, radius: f32, ) -> impl Fn(&Theme, button::Status) -> button::Style { move |_: &Theme, status: button::Status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if active { - if hover { - lighten(Color::from_rgb(0.14, 0.14, 0.20), 0.04) - } else { - Color::from_rgb(0.14, 0.14, 0.20) - } - } else if hover { - lighten(BG_BUTTON, 0.04) + let hover = is_hovered(status); + let (background, border, text_color) = if active { + ( + color.scale_alpha(if hover { 0.24 } else { 0.16 }), + color.scale_alpha(0.5), + color, + ) } else { - BG_BUTTON + ( + if hover { + Palette::BG_CARD_HOVER() + } else { + Color::TRANSPARENT + }, + control_outline(), + Palette::TEXT_MUTED(), + ) }; button::Style { - background: Some(Background::Color(bg)), + background: Some(Background::Color(background)), text_color, border: Border { radius: radius.into(), - color: if active { - Color { a: 0.4, ..accent } - } else if hover { - Color::from_rgb(0.2, 0.2, 0.28) - } else { - Color::TRANSPARENT - }, + color: border, width: 1.0, }, ..button::Style::default() @@ -268,64 +170,46 @@ pub fn chip_style( /// Invisible button wrapper with subtle hover feedback. pub fn transparent_btn_style() -> impl Fn(&Theme, button::Status) -> button::Style { - |_: &Theme, status: button::Status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - button::Style { - background: if hover { - Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.03))) - } else { - None - }, - border: Border { - radius: 8.0.into(), - ..Border::default() - }, - ..button::Style::default() - } + |_: &Theme, status: button::Status| button::Style { + background: is_hovered(status).then(|| Background::Color(Palette::BG_CARD_HOVER())), + text_color: Palette::TEXT_PRIMARY(), + border: Border { + radius: 8.0.into(), + ..Border::default() + }, + ..button::Style::default() } } -/// Solid primary action button with hover lightening. +/// Solid action button filled with `bg`, its label in whichever of near-black +/// or near-white reads on it. Hovering lets a little of the surface through. pub fn primary_btn_style(bg: Color) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, status: button::Status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - button::Style { - background: Some(Background::Color(if hover { - lighten(bg, 0.08) - } else { - bg - })), - text_color: BG_DARKER, - border: Border { - radius: 8.0.into(), - ..Border::default() - }, - ..button::Style::default() - } + move |_: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(if is_hovered(status) { + bg.scale_alpha(0.85) + } else { + bg + })), + text_color: contrast_on(bg), + border: Border { + radius: 8.0.into(), + ..Border::default() + }, + ..button::Style::default() } } -/// Subtle outlined button with hover fill. -pub fn outline_btn_style( - color: Color, - bg: Color, -) -> impl Fn(&Theme, button::Status) -> button::Style { +/// Button outlined and labelled in `color`, tinted on hover. Transparent, so it +/// sits on any surface. +pub fn outline_btn_style(color: Color) -> impl Fn(&Theme, button::Status) -> button::Style { move |_: &Theme, status: button::Status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); + let hover = is_hovered(status); button::Style { - background: Some(Background::Color(if hover { - lighten(bg, 0.04) - } else { - bg - })), + background: hover.then(|| Background::Color(color.scale_alpha(0.12))), text_color: color, border: Border { radius: 8.0.into(), - color: if hover { - Color { a: 0.5, ..color } - } else { - Color { a: 0.35, ..color } - }, + color: color.scale_alpha(if hover { 0.5 } else { 0.35 }), width: 1.0, }, ..button::Style::default() @@ -336,38 +220,39 @@ pub fn outline_btn_style( /// Disabled / muted button (no hover effect). pub fn disabled_btn_style() -> impl Fn(&Theme, button::Status) -> button::Style { |_: &Theme, _: button::Status| button::Style { - background: Some(Background::Color(BG_BUTTON)), - text_color: TEXT_HEADING, + background: None, + text_color: Palette::TEXT_DIMMER(), border: Border { radius: 8.0.into(), - color: BORDER_CARD, + color: control_outline(), width: 1.0, }, ..button::Style::default() } } -/// Pulsing/active state button (gold tint for "importing" states, no hover). +/// Work in progress, such as an import or a login waiting on the browser +/// (warning tint, no hover). pub fn loading_btn_style() -> impl Fn(&Theme, button::Status) -> button::Style { |_: &Theme, _: button::Status| button::Style { - background: Some(Background::Color(Color::from_rgb(0.14, 0.13, 0.08))), - text_color: ACCENT_GOLD, + background: Some(Background::Color(Palette::WARNING_BG())), + text_color: Palette::WARNING(), border: Border { radius: 8.0.into(), - color: Color::from_rgb(0.3, 0.28, 0.1), + color: Palette::WARNING().scale_alpha(0.4), width: 1.0, }, ..button::Style::default() } } -/// Card with an accent-colored left border (platform branding). -pub fn accent_card_style(bg: Color, accent: Color) -> impl Fn(&Theme) -> container::Style { +/// Card with a border tinted by `accent` (platform branding). +pub fn accent_card_style(accent: Color) -> impl Fn(&Theme) -> container::Style { move |_: &Theme| container::Style { - background: Some(Background::Color(bg)), + background: Some(Background::Color(Palette::BG_CARD())), border: Border { radius: 10.0.into(), - color: Color { a: 0.25, ..accent }, + color: accent.scale_alpha(0.25), width: 1.0, }, ..container::Style::default() @@ -386,38 +271,21 @@ pub fn dot_style(color: Color, size: f32) -> impl Fn(&Theme) -> container::Style } } -/// Badge container style (platform/status pills in detail view). -pub fn badge_style(border_color: Color) -> impl Fn(&Theme) -> container::Style { - move |_: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb(0.12, 0.12, 0.18))), +/// Danger button for delete actions, with hover effect. +pub fn danger_btn_style() -> impl Fn(&Theme, button::Status) -> button::Style { + |_: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(if is_hovered(status) { + Palette::BTN_DANGER_HOVER() + } else { + Palette::BTN_DANGER_BG() + })), + text_color: Palette::ERROR(), border: Border { - radius: 12.0.into(), - color: border_color, + radius: 6.0.into(), + color: Palette::ERROR().scale_alpha(0.5), width: 1.0, }, - ..container::Style::default() - } -} - -/// Danger button (red) with hover effect for delete actions. -pub fn danger_btn_style() -> impl Fn(&Theme, button::Status) -> button::Style { - |_: &Theme, status: button::Status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if hover { - Color::from_rgb(0.25, 0.08, 0.08) - } else { - Color::from_rgb(0.2, 0.05, 0.05) - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: Color::from_rgb(1.0, 0.4, 0.4), - border: Border { - radius: 6.0.into(), - color: Color::from_rgb(0.5, 0.15, 0.15), - width: 1.0, - }, - ..button::Style::default() - } + ..button::Style::default() } } @@ -433,3 +301,24 @@ pub fn pill_style(bg: Color, border_color: Color) -> impl Fn(&Theme) -> containe ..container::Style::default() } } + +#[cfg(test)] +mod tests { + use super::glyph_runs; + + #[test] + fn glyphs_are_split_from_the_text_around_them() { + let runs: Vec<_> = glyph_runs("\u{f0322} Library \u{f005}\u{f006}!").collect(); + assert_eq!( + runs, + [ + ("\u{f0322}", true), + (" Library ", false), + ("\u{f005}\u{f006}", true), + ("!", false), + ] + ); + assert_eq!(glyph_runs("Plain").collect::>(), [("Plain", false)]); + assert_eq!(glyph_runs("").count(), 0); + } +} diff --git a/src/views/add_game.rs b/src/views/add_game.rs index f77aed2..23cff0e 100644 --- a/src/views/add_game.rs +++ b/src/views/add_game.rs @@ -1,131 +1,90 @@ -use iced::widget::{button, column, container, row, rule, text, text_input}; -use iced::{Background, Border, Color, Element, Length, Theme}; +use colony_ui::theme::Palette; +use colony_ui::{sz, ui_font}; +use iced::widget::{button, column, container, row, rule, text_input}; +use iced::{Color, Element, Length}; use crate::app::{Message, Screen, Spotter}; +use crate::i18n::t; use crate::models::{GameStatus, Platform}; -use crate::theme::{self, ViewTheme}; +use crate::theme::{self, ui_text}; pub fn view(app: &Spotter) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); - let header = column![ - text(format!("{} Add Game", theme::icons::ADD)) - .size(24) - .font(theme::FONT_BOLD) - .color(Color::WHITE), - text("Manually add a game to your library") - .size(12) - .color(vt.text_muted), + ui_text(format!("{} {}", theme::icons::ADD, t("nav_add_game")), 24) + .font(colony_ui::ui_font_bold()) + .color(Palette::TEXT_PRIMARY()), + ui_text(t("add_subtitle"), 12).color(Palette::TEXT_MUTED()), ] .spacing(4); // Title input let title_section = labeled_section( - "Game Title *", - text_input("Enter game title...", &app.new_game_title) + t("add_title_label"), + text_input(t("add_title_placeholder"), &app.new_game_title) .on_input(Message::AddGameTitleChanged) .padding(10) - .size(14), + .size(sz(14)) + .font(ui_font()), ); // Genre input let genre_section = labeled_section( - "Genre", - text_input("e.g. RPG, Action, Adventure...", &app.new_game_genre) + t("detail_genre"), + text_input(t("add_genre_placeholder"), &app.new_game_genre) .on_input(Message::AddGameGenreChanged) .padding(10) - .size(14), + .size(sz(14)) + .font(ui_font()), ); // Platform picker let platform_section = labeled_section( - "Platform", - row![ - platform_btn("Steam", Platform::Steam, app.new_game_platform), - platform_btn("GOG", Platform::Gog, app.new_game_platform), - platform_btn("Epic", Platform::Epic, app.new_game_platform), - platform_btn("PlayStation", Platform::PlayStation, app.new_game_platform), - platform_btn("Xbox", Platform::Xbox, app.new_game_platform), - platform_btn("Nintendo", Platform::Nintendo, app.new_game_platform), - ] - .spacing(6), + t("add_platform"), + row(Platform::ALL.map(|platform| { + choice_chip( + platform.to_string(), + app.new_game_platform == platform, + platform.color(), + Message::AddGamePlatformChanged(platform), + ) + })) + .spacing(6) + .wrap(), ); // Status picker let status_section = labeled_section( - "Status", - row![ - status_btn("Unplayed", GameStatus::Unplayed, app.new_game_status), - status_btn("Playing", GameStatus::Playing, app.new_game_status), - status_btn("Completed", GameStatus::Completed, app.new_game_status), - status_btn("Dropped", GameStatus::Dropped, app.new_game_status), - status_btn("Wishlist", GameStatus::Wishlist, app.new_game_status), - ] - .spacing(6), + t("add_status"), + row(GameStatus::ALL.map(|status| { + choice_chip( + status.label().to_owned(), + app.new_game_status == status, + status.color(), + Message::AddGameStatusChanged(status), + ) + })) + .spacing(6) + .wrap(), ); // Buttons let can_save = !app.new_game_title.trim().is_empty(); let save_btn = if can_save { - button(text("Add to Library").size(14).color(theme::BG_DARKER)) + button(ui_text(t("add_save"), 14)) .on_press(Message::SaveNewGame) .padding([10, 24]) - .style(|_: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if hover { - crate::theme::lighten(theme::SUCCESS, 0.04) - } else { - theme::SUCCESS - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: theme::BG_DARKER, - border: Border { - radius: 8.0.into(), - ..Border::default() - }, - ..button::Style::default() - } - }) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else { - button(text("Add to Library").size(14).color(theme::TEXT_HEADING)) + button(ui_text(t("add_save"), 14)) .padding([10, 24]) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(theme::BG_BUTTON)), - text_color: theme::TEXT_HEADING, - border: Border { - radius: 8.0.into(), - color: theme::BORDER_CARD, - width: 1.0, - }, - ..button::Style::default() - }) + .style(theme::disabled_btn_style()) }; - let cancel_btn = button(text("Cancel").size(13).color(theme::TEXT_SECONDARY)) + let cancel_btn = button(ui_text(t("action_cancel"), 13)) .on_press(Message::NavigateTo(Screen::Library)) .padding([8, 16]) - .style(|_: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if hover { - crate::theme::lighten(theme::BG_BUTTON, 0.04) - } else { - theme::BG_BUTTON - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: theme::TEXT_SECONDARY, - border: Border { - radius: 6.0.into(), - color: theme::BORDER_CARD, - width: 1.0, - }, - ..button::Style::default() - } - }); + .style(theme::outline_btn_style(Palette::TEXT_SECONDARY())); - let bg_card = vt.bg_card; - let border_color = vt.border; let form_card = container( column![ title_section, @@ -140,15 +99,11 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { ) .padding(20) .width(Length::Fill) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg_card)), - border: Border { - radius: 8.0.into(), - color: border_color, - width: 1.0, - }, - ..container::Style::default() - }); + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 8.0, + )); let content = column![header, rule::horizontal(1), form_card,] .spacing(16) @@ -163,97 +118,23 @@ fn labeled_section<'a>( content: impl Into>, ) -> Element<'a, Message> { column![ - text(label).size(13).color(theme::TEXT_LIGHT), + ui_text(label, 13).color(Palette::TEXT_PRIMARY()), content.into(), ] .spacing(6) .into() } -fn platform_btn<'a>(label: &'a str, target: Platform, current: Platform) -> Element<'a, Message> { - let is_active = current == target; - let color = if is_active { - target.color() - } else { - theme::TEXT_MUTED - }; - - button(text(label).size(12).color(color)) - .on_press(Message::AddGamePlatformChanged(target)) - .padding([6, 12]) - .style(move |_: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if is_active { - if hover { - crate::theme::lighten(Color::from_rgb(0.15, 0.15, 0.22), 0.04) - } else { - Color::from_rgb(0.15, 0.15, 0.22) - } - } else if hover { - crate::theme::lighten(theme::BG_BUTTON, 0.04) - } else { - theme::BG_BUTTON - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 8.0.into(), - color: if is_active { - color - } else if hover { - Color::from_rgb(0.2, 0.2, 0.28) - } else { - Color::TRANSPARENT - }, - width: 1.0, - }, - ..button::Style::default() - } - }) - .into() -} - -fn status_btn<'a>(label: &'a str, target: GameStatus, current: GameStatus) -> Element<'a, Message> { - let is_active = current == target; - let color = if is_active { - target.color() - } else { - theme::TEXT_MUTED - }; - - button(text(label).size(12).color(color)) - .on_press(Message::AddGameStatusChanged(target)) +/// One choice of a picker row, tinted with the choice's own colour when picked. +fn choice_chip( + label: String, + active: bool, + color: Color, + msg: Message, +) -> Element<'static, Message> { + button(ui_text(label, 12)) + .on_press(msg) .padding([6, 12]) - .style(move |_: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if is_active { - if hover { - crate::theme::lighten(Color::from_rgb(0.15, 0.15, 0.22), 0.04) - } else { - Color::from_rgb(0.15, 0.15, 0.22) - } - } else if hover { - crate::theme::lighten(theme::BG_BUTTON, 0.04) - } else { - theme::BG_BUTTON - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 8.0.into(), - color: if is_active { - color - } else if hover { - Color::from_rgb(0.2, 0.2, 0.28) - } else { - Color::TRANSPARENT - }, - width: 1.0, - }, - ..button::Style::default() - } - }) + .style(theme::chip_style(active, color, 8.0)) .into() } diff --git a/src/views/detail.rs b/src/views/detail.rs index abf23a0..cc95437 100644 --- a/src/views/detail.rs +++ b/src/views/detail.rs @@ -1,33 +1,28 @@ -use iced::widget::{button, column, container, progress_bar, row, rule, space, text, text_input}; -use iced::{Background, Border, Color, Element, Length, Theme}; +use colony_ui::theme::Palette; +use colony_ui::{sz, ui_font}; +use iced::widget::{button, column, container, progress_bar, row, rule, space, text_input}; +use iced::{Background, Border, Element, Length, Theme}; use crate::app::{Message, Screen, Spotter}; -use crate::models::{AchievementsDisplay, GameStatus}; -use crate::theme::{self, ViewTheme}; +use crate::i18n::{percent, t, tf}; +use crate::models::{AchievementsDisplay, GameStatus, Platform}; +use crate::theme::{self, ui_text}; pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); + let date_format = app.preferences.date_format; let game = match app.games.iter().find(|g| g.id == Some(game_id)) { Some(g) => g, None => { return container( column![ - text("Game not found").size(20).color(theme::TEXT_LIGHT), - button( - text(format!("{} Back to Library", theme::icons::BACK)) - .size(13) - .color(theme::ACCENT_BLUE), - ) + ui_text(t("detail_not_found"), 20).color(Palette::TEXT_PRIMARY()), + button(ui_text( + format!("{} {}", theme::icons::BACK, t("detail_back")), + 13 + )) .on_press(Message::NavigateTo(Screen::Library)) .padding([8, 16]) - .style(|_: &Theme, _| button::Style { - background: Some(Background::Color(theme::BG_BUTTON)), - border: Border { - radius: 6.0.into(), - ..Border::default() - }, - ..button::Style::default() - }), + .style(theme::outline_btn_style(Palette::ACCENT())), ] .spacing(12), ) @@ -36,35 +31,17 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { } }; - let back_label = format!("{} Back to Library", theme::icons::BACK); - let back_btn = button(text(back_label).size(13).color(theme::ACCENT_BLUE)) + let back_label = format!("{} {}", theme::icons::BACK, t("detail_back")); + let back_btn = button(ui_text(back_label, 13)) .on_press(Message::NavigateTo(Screen::Library)) .padding([6, 12]) - .style(|_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if hover { - Color::from_rgb( - theme::BG_BUTTON.r + 0.04, - theme::BG_BUTTON.g + 0.04, - theme::BG_BUTTON.b + 0.04, - ) - } else { - theme::BG_BUTTON - }; - button::Style { - background: Some(Background::Color(bg)), - border: Border { - radius: 6.0.into(), - ..Border::default() - }, - ..button::Style::default() - } - }); + .style(theme::outline_btn_style(Palette::ACCENT())); // Cover image let cover_element: Element<'_, Message> = { - let cover_path = crate::images::cover_path(&game.title); - if app.cover_cache.contains(&game.title) { + let cover_path = crate::images::cover_path(&game.title) + .filter(|_| app.cover_cache.contains(&game.title)); + if let Some(cover_path) = cover_path { container( iced::widget::image(iced::widget::image::Handle::from_path(&cover_path)) .width(460) @@ -73,7 +50,7 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { .style(|_: &Theme| container::Style { border: Border { radius: 8.0.into(), - color: theme::BORDER_CARD, + color: Palette::BORDER_SUBTLE(), width: 1.0, }, ..container::Style::default() @@ -82,8 +59,8 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { } else { container( column![ - text(&game.title).size(20).color(theme::TEXT_HEADING), - text("No cover image").size(12).color(vt.text_muted), + ui_text(&game.title, 20).color(Palette::TEXT_DIMMER()), + ui_text(t("detail_no_cover"), 12).color(Palette::TEXT_MUTED()), ] .spacing(4) .align_x(iced::Alignment::Center), @@ -92,90 +69,55 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { .height(215) .center_x(460) .center_y(215) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(theme::BG_DARK)), - border: Border { - radius: 8.0.into(), - color: theme::BORDER, - width: 1.0, - }, - ..container::Style::default() - }) + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 8.0, + )) .into() } }; - let title = text(&game.title).size(32).color(Color::WHITE); + let title = ui_text(&game.title, 32).color(Palette::TEXT_PRIMARY()); // Favorite toggle - let is_fav = app.settings.favorites.contains(&game_id); + let is_fav = app.preferences.favorites.contains(&game_id); let fav_label = if is_fav { - format!("{} Favorited", theme::icons::STAR) + format!("{} {}", theme::icons::STAR, t("detail_favorited")) } else { - format!("{} Favorite", theme::icons::STAR_O) + format!("{} {}", theme::icons::STAR_O, t("detail_favorite")) }; - let fav_color = if is_fav { - theme::ACCENT_GOLD - } else { - theme::TEXT_MUTED - }; - let fav_btn = button(text(fav_label).size(13).color(fav_color)) + let fav_btn = button(ui_text(fav_label, 13)) .on_press(Message::ToggleFavorite(game_id)) .padding([6, 14]) - .style(theme::chip_style( - is_fav, - fav_color, - theme::ACCENT_GOLD, - 8.0, - )); + .style(theme::chip_style(is_fav, Palette::WARNING(), 8.0)); let platform_badge = container( - text(format!("{} {}", game.platform.icon(), game.platform)) - .size(13) + ui_text(format!("{} {}", game.platform.icon(), game.platform), 13) .color(game.platform.color()), ) .padding([4, 10]) - .style(theme::badge_style(game.platform.color())); + .style(theme::pill_style(Palette::BG_CARD(), game.platform.color())); let status_badge = container( - text(format!("{} {}", game.status.icon(), game.status)) - .size(13) - .color(game.status.color()), + ui_text( + format!("{} {}", game.status.icon(), game.status.label()), + 13, + ) + .color(game.status.color()), ) .padding([4, 10]) - .style(theme::badge_style(game.status.color())); + .style(theme::pill_style(Palette::BG_CARD(), game.status.color())); let mut badges = row![platform_badge, status_badge, fav_btn].spacing(8); if let Some(appid) = game.steam_appid { - let steam_color = Color::from_rgb(0.4, 0.6, 0.9); - let steam_badge = button( - text(format!("Steam #{}", appid)) - .size(11) - .color(steam_color), - ) - .on_press(Message::OpenUrl(format!( - "https://store.steampowered.com/app/{}", - appid - ))) - .padding([4, 10]) - .style(move |_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if hover { - Color::from_rgb(0.14, 0.14, 0.2) - } else { - Color::from_rgb(0.1, 0.1, 0.15) - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: steam_color, - border: Border { - radius: 12.0.into(), - color: Color::from_rgb(0.2, 0.3, 0.5), - width: 1.0, - }, - ..button::Style::default() - } - }); + let steam_badge = button(ui_text(format!("Steam #{}", appid), 11)) + .on_press(Message::OpenUrl(format!( + "https://store.steampowered.com/app/{}", + appid + ))) + .padding([4, 10]) + .style(theme::outline_btn_style(Platform::Steam.color())); badges = badges.push(steam_badge); } @@ -183,51 +125,54 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { let playtime_str = game.playtime_display(); let rating_str = match game.rating { Some(r) => format!("{}/10", r), - None => "Not rated".to_string(), + None => t("rating_none").to_owned(), }; let review_str = match game.review_percent { Some(pct) => { - let label = if pct >= 95 { - "Overwhelmingly Positive" + // Steam's own words for its review scores. + let label = t(if pct >= 95 { + "review_overwhelmingly_positive" } else if pct >= 80 { - "Very Positive" + "review_very_positive" } else if pct >= 70 { - "Mostly Positive" + "review_mostly_positive" } else if pct >= 40 { - "Mixed" + "review_mixed" } else if pct >= 20 { - "Mostly Negative" + "review_mostly_negative" } else { - "Very Negative" - }; - format!("{} ({}% positive)", label, pct) + "review_very_negative" + }); + tf( + "detail_review", + &[("label", &label), ("percent", &percent(pct as f32, 0))], + ) } - None => "No reviews".to_string(), + None => t("detail_no_reviews").to_owned(), }; let release_str = if game.release_date.is_empty() { - "Unknown".to_string() + t("detail_unknown").to_owned() } else { - vt.date_format.format_date(&game.release_date) + date_format.format_date(&game.release_date) }; let mut info_col = column![ - info_row("Genre", &game.genre), - info_row("Release Date", &release_str), - info_row("Last Played", vt.date_format.format_date(&game.last_played)), - info_row("Playtime", &playtime_str), - info_row("Rating", &rating_str), - info_row("Reviews", &review_str), + info_row(t("detail_genre"), &game.genre), + info_row(t("detail_release_date"), &release_str), + info_row( + t("detail_last_played"), + date_format.format_date(&game.last_played) + ), + info_row(t("detail_playtime"), &playtime_str), + info_row(t("detail_rating"), &rating_str), + info_row(t("detail_reviews"), &review_str), ] .spacing(8); // Description if !game.description.is_empty() { info_col = info_col.push(rule::horizontal(1)); - info_col = info_col.push( - text(&game.description) - .size(12) - .color(theme::TEXT_SECONDARY), - ); + info_col = info_col.push(ui_text(&game.description, 12).color(Palette::TEXT_SECONDARY())); } // Tags @@ -235,57 +180,56 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { info_col = info_col.push(rule::horizontal(1)); info_col = info_col.push( row![ - text("Tags: ").size(11).color(theme::TEXT_MUTED), - text(&game.tags).size(11).color(theme::TEXT_SECONDARY), + ui_text(t("detail_tags"), 11).color(Palette::TEXT_MUTED()), + ui_text(&game.tags, 11).color(Palette::TEXT_SECONDARY()), ] .spacing(4), ); } - let info_section = info_card("Game Info", vt, info_col); + let info_section = info_card(t("detail_info"), info_col); - // Achievements section — summary + individual list - let ach_display = app.settings.achievements_display; + // Achievements section: summary + individual list + let ach_display = app.preferences.achievements_display; let achievements_section = if app.achievements_loading { info_card( - "Achievements", - vt, - column![text("Loading achievements...") - .size(14) - .color(vt.text_muted),] - .spacing(10), + t("detail_achievements"), + column![ui_text(t("detail_achievements_loading"), 14).color(Palette::TEXT_MUTED()),] + .spacing(10), ) } else if ach_display == AchievementsDisplay::Hidden { // Hidden: don't show achievements at all - container(text("").size(1)).into() + container(space()).into() } else if game.achievements_total > 0 { let pct = game.achievement_percent(); let mut ach_col = column![ row![ - text(format!( - "{} / {}", - game.achievements_unlocked, game.achievements_total - )) - .size(20) - .color(Color::WHITE), + ui_text( + format!( + "{} / {}", + game.achievements_unlocked, game.achievements_total + ), + 20 + ) + .color(Palette::TEXT_PRIMARY()), space::horizontal(), - text(format!("{:.1}%", pct)) - .size(20) - .color(if pct >= 100.0 { - theme::ACCENT_GOLD - } else { - theme::ACCENT_BLUE - }), + ui_text(percent(pct, 1), 20).color(if pct >= 100.0 { + Palette::WARNING() + } else { + Palette::ACCENT() + }), ] .align_y(iced::Alignment::Center), progress_bar(0.0..=100.0, pct).girth(12), - text(if pct >= 100.0 { - "All achievements unlocked!" - } else { - "Keep going!" - }) - .size(12) - .color(theme::TEXT_MUTED), + ui_text( + t(if pct >= 100.0 { + "detail_all_unlocked" + } else { + "detail_keep_going" + }), + 12 + ) + .color(Palette::TEXT_MUTED()), ] .spacing(10); @@ -295,63 +239,46 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { for ach in &app.achievements { let (name_color, desc_color) = if ach.unlocked { - (Color::WHITE, theme::TEXT_SECONDARY) + (Palette::TEXT_PRIMARY(), Palette::TEXT_SECONDARY()) } else { - (theme::TEXT_MUTED, theme::TEXT_DIM) + (Palette::TEXT_MUTED(), Palette::TEXT_DIM()) }; // Achievement icon: show colored icon if unlocked, gray if locked // Falls back to colored icon for locked if gray is unavailable - let icon_element: Element<'_, Message> = if let Some(appid) = game.steam_appid { + let icon_path = game.steam_appid.and_then(|appid| { let gray_path = crate::images::achievement_icon_path(appid, &ach.api_name, true); let colored_path = crate::images::achievement_icon_path(appid, &ach.api_name, false); - let icon_path = if ach.unlocked { + if ach.unlocked { // Unlocked: use colored icon colored_path - } else if gray_path.exists() { + } else if gray_path.as_ref().is_some_and(|p| p.exists()) { // Locked: prefer gray icon gray_path } else { // Locked but no gray icon: fall back to colored colored_path - }; - if icon_path.exists() { - let img = - iced::widget::image(iced::widget::image::Handle::from_path(&icon_path)) - .width(48) - .height(48); - let is_locked = !ach.unlocked; - container(img) + } + }); + let icon_element: Element<'_, Message> = + if let Some(icon_path) = icon_path.filter(|p| p.exists()) { + iced::widget::image(iced::widget::image::Handle::from_path(&icon_path)) .width(48) .height(48) - .style(move |_: &Theme| container::Style { - background: if is_locked { - // Dim overlay for locked achievements using colored icon - Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.3))) - } else { - None - }, - border: Border { - radius: 4.0.into(), - ..Border::default() - }, - ..container::Style::default() - }) .into() } else { - // Fallback: colored dot while icons are downloading + // Fallback: colored square while icons are downloading, or for + // games outside Steam let dot = if ach.unlocked { - Color::from_rgb(0.2, 0.9, 0.4) + Palette::SUCCESS() } else { - Color::from_rgb(0.35, 0.35, 0.4) + Palette::TEXT_DIMMEST() }; - container(text("").size(1)) + container(space()) .width(48) .height(48) - .center_x(48) - .center_y(48) .style(move |_: &Theme| container::Style { background: Some(Background::Color(dot)), border: Border { @@ -361,45 +288,22 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { ..container::Style::default() }) .into() - } - } else { - // Non-Steam game: fallback dot - let dot = if ach.unlocked { - Color::from_rgb(0.2, 0.9, 0.4) - } else { - Color::from_rgb(0.35, 0.35, 0.4) }; - container(text("").size(1)) - .width(48) - .height(48) - .center_x(48) - .center_y(48) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(dot)), - border: Border { - radius: 4.0.into(), - ..Border::default() - }, - ..container::Style::default() - }) - .into() - }; let mut ach_info = - column![text(&ach.display_name).size(13).color(name_color),].spacing(2); + column![ui_text(&ach.display_name, 13).color(name_color),].spacing(2); if !ach.description.is_empty() { - ach_info = ach_info.push(text(&ach.description).size(11).color(desc_color)); + ach_info = ach_info.push(ui_text(&ach.description, 11).color(desc_color)); } // Show unlock date for unlocked achievements if ach.unlocked && ach.unlock_time > 0 { - let datetime = vt.date_format.format_timestamp(ach.unlock_time); + let datetime = date_format.format_timestamp(ach.unlock_time); if !datetime.is_empty() { ach_info = ach_info.push( - text(format!("Unlocked {}", datetime)) - .size(10) - .color(theme::SUCCESS), + ui_text(tf("detail_unlocked_on", &[("date", &datetime)]), 10) + .color(Palette::SUCCESS()), ); } } @@ -408,13 +312,12 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { .spacing(12) .align_y(iced::Alignment::Center); - let ach_bg = vt.bg_row; ach_col = ach_col.push( container(ach_row) .padding([6, 8]) .width(Length::Fill) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(ach_bg)), + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Palette::BG_PRIMARY())), border: Border { radius: 4.0.into(), ..Border::default() @@ -425,55 +328,45 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { } } - info_card("Achievements", vt, ach_col) + info_card(t("detail_achievements"), ach_col) } else { info_card( - "Achievements", - vt, - column![text("No achievements available for this game") - .size(14) - .color(vt.text_muted),] - .spacing(10), + t("detail_achievements"), + column![ui_text(t("detail_no_achievements"), 14).color(Palette::TEXT_MUTED()),] + .spacing(10), ) }; // Notes section let notes_len = game.notes.len(); let (notes_counter_color, notes_hint) = if notes_len >= 2000 { - (Color::from_rgb(1.0, 0.3, 0.3), " - limit reached") + (Palette::ERROR(), Some("detail_notes_full")) } else if notes_len > 1800 { - (Color::from_rgb(1.0, 0.5, 0.3), " - approaching limit") + (Palette::WARNING(), Some("detail_notes_nearly_full")) } else { - (theme::TEXT_DIM, "") + (Palette::TEXT_DIM(), None) }; + let notes_hint = notes_hint.map_or_else(String::new, |key| format!(" - {}", t(key))); let notes_section = info_card( - "Notes", - vt, + t("detail_notes"), column![ - text_input("Add notes about this game...", &game.notes) + text_input(t("detail_notes_placeholder"), &game.notes) .on_input(move |s| Message::SetGameNotes(game_id, s)) .padding(8) - .size(13), - text(format!("{} / 2000{}", notes_len, notes_hint)) - .size(11) - .color(notes_counter_color), + .size(sz(13)) + .font(ui_font()), + ui_text(format!("{} / 2000{}", notes_len, notes_hint), 11).color(notes_counter_color), ] .spacing(8), ); // Status change buttons (respect large_click_targets setting) - let bp = vt.btn_padding; + let bp = app.preferences.button_padding(); let status_section = info_card( - "Change Status", - vt, - row![ - status_button("Playing", GameStatus::Playing, game.status, game_id, bp), - status_button("Completed", GameStatus::Completed, game.status, game_id, bp), - status_button("Unplayed", GameStatus::Unplayed, game.status, game_id, bp), - status_button("Dropped", GameStatus::Dropped, game.status, game_id, bp), - status_button("Wishlist", GameStatus::Wishlist, game.status, game_id, bp), - ] - .spacing(6), + t("detail_change_status"), + row(GameStatus::ALL.map(|status| status_button(status, game.status, game_id, bp))) + .spacing(6) + .wrap(), ); // Rating section @@ -482,21 +375,20 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { .collect(); if game.rating.is_some() { rating_items.push( - button(text("Clear").size(11).color(theme::TEXT_MUTED)) + button(ui_text(t("detail_rating_clear"), 11)) .on_press(Message::SetGameRating(game_id, None)) .padding([6, 10]) - .style(theme::outline_btn_style(theme::TEXT_MUTED, vt.bg_card)) + .style(theme::outline_btn_style(Palette::TEXT_MUTED())) .into(), ); } - let rating_section = info_card("Rate this Game", vt, row(rating_items).spacing(4)); + let rating_section = info_card(t("detail_rate"), row(rating_items).spacing(4)); // Top bar: Back button (left) + Delete button (right) - let delete_btn = button( - text(format!("{} Delete", theme::icons::DELETE)) - .size(13) - .color(theme::DANGER), - ) + let delete_btn = button(ui_text( + format!("{} {}", theme::icons::DELETE, t("action_delete")), + 13, + )) .on_press(Message::ConfirmDeleteGame(game_id)) .padding([6, 12]) .style(theme::danger_btn_style()); @@ -522,75 +414,48 @@ pub fn view(app: &Spotter, game_id: i64) -> Element<'_, Message> { iced::widget::scrollable(container(content).width(Length::Fill)).into() } -fn info_card<'a>( - title: &'a str, - vt: ViewTheme, - content: impl Into>, -) -> Element<'a, Message> { - let section = column![text(title).size(14).color(vt.text_muted), content.into(),].spacing(8); +fn info_card<'a>(title: &'a str, content: impl Into>) -> Element<'a, Message> { + let section = column![ + ui_text(title, 14).color(Palette::TEXT_MUTED()), + content.into(), + ] + .spacing(8); - let bg = vt.bg_card; - let border = vt.border; container(section) .padding(16) .width(Length::Fill) - .style(move |_theme: &Theme| container::Style { - background: Some(Background::Color(bg)), - border: Border { - radius: 8.0.into(), - color: border, - width: 1.0, - }, - ..container::Style::default() - }) + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 8.0, + )) .into() } fn info_row<'a>(label: &'a str, value: impl ToString) -> Element<'a, Message> { row![ - text(label).size(13).color(theme::TEXT_MUTED).width(120), - text(value.to_string()).size(13).color(Color::WHITE), + // Room for the longer French labels at every text size. + ui_text(label, 13) + .color(Palette::TEXT_MUTED()) + .width(colony_ui::sz(150)), + ui_text(value.to_string(), 13).color(Palette::TEXT_PRIMARY()), ] .spacing(12) .into() } fn status_button<'a>( - label: &'a str, target: GameStatus, current: GameStatus, game_id: i64, pad: u16, ) -> Element<'a, Message> { - let is_active = current == target; let color = target.color(); - - button(text(label).size(12).color(color)) + // Every status keeps its own colour, picked or not. + button(ui_text(target.label(), 12).color(color)) .on_press(Message::SetGameStatus(game_id, target)) .padding([pad, pad + 4]) - .style(move |_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if is_active { - Color::from_rgb(0.15, 0.18, 0.25) - } else { - theme::BG_DARK - }; - let bg = if hover { - Color::from_rgb(bg.r + 0.04, bg.g + 0.04, bg.b + 0.04) - } else { - bg - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 6.0.into(), - color: if is_active { color } else { theme::BORDER_CARD }, - width: 1.0, - }, - ..button::Style::default() - } - }) + .style(theme::chip_style(current == target, color, 6.0)) .into() } @@ -601,41 +466,9 @@ fn rating_button<'a>( pad: u16, ) -> Element<'a, Message> { let is_active = current.is_some_and(|r| value <= r); - let color = if is_active { - theme::ACCENT_GOLD - } else { - theme::TEXT_HEADING - }; - - button(text(format!("{}", value)).size(13).color(color)) + button(ui_text(value.to_string(), 13)) .on_press(Message::SetGameRating(game_id, Some(value))) .padding([pad, pad + 4]) - .style(move |_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if is_active { - Color::from_rgb(0.2, 0.18, 0.08) - } else { - theme::BG_DARK - }; - let bg = if hover { - Color::from_rgb(bg.r + 0.04, bg.g + 0.04, bg.b + 0.04) - } else { - bg - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 4.0.into(), - color: if is_active { - Color::from_rgb(0.5, 0.42, 0.15) - } else { - theme::BORDER_CARD - }, - width: 1.0, - }, - ..button::Style::default() - } - }) + .style(theme::chip_style(is_active, Palette::WARNING(), 4.0)) .into() } diff --git a/src/views/helpers.rs b/src/views/helpers.rs index cc21a25..1f8c278 100644 --- a/src/views/helpers.rs +++ b/src/views/helpers.rs @@ -1,12 +1,14 @@ -use iced::widget::{container, text}; -use iced::{Background, Color, Element, Length, Theme}; +use colony_ui::theme::Palette; +use iced::widget::{button, column, container, row, space, stack}; +use iced::{Background, Border, Color, Element, Length, Theme}; use crate::app::Message; -use crate::theme; +use crate::i18n::t; +use crate::theme::{self, ui_text}; /// Small colored dot used as a platform indicator. pub fn platform_dot(color: Color) -> Element<'static, Message> { - container(text("").size(1)) + container(space()) .width(12) .height(12) .style(theme::dot_style(color, 12.0)) @@ -15,9 +17,9 @@ pub fn platform_dot(color: Color) -> Element<'static, Message> { /// Status badge pill with colored background/border. pub fn status_badge<'a>(label: &'a str, color: Color) -> Element<'a, Message> { - let bg = Color { a: 0.15, ..color }; - let border = Color { a: 0.3, ..color }; - container(text(label).size(11).color(color)) + let bg = color.scale_alpha(0.15); + let border = color.scale_alpha(0.3); + container(ui_text(label, 11).color(color)) .padding([3, 10]) .style(theme::pill_style(bg, border)) .into() @@ -25,12 +27,72 @@ pub fn status_badge<'a>(label: &'a str, color: Color) -> Element<'a, Message> { /// Thin colored separator line. pub fn separator(color: Color) -> Element<'static, Message> { - container(text("").size(1)) + container(space()) .width(Length::Fill) .height(1) .style(move |_: &Theme| container::Style { - background: Some(Background::Color(Color { a: 0.12, ..color })), + background: Some(Background::Color(color.scale_alpha(0.12))), ..container::Style::default() }) .into() } + +/// Delete confirmation over a dimmed page. Clicking outside the card cancels. +pub fn delete_modal<'a>( + title: &str, + question: String, + confirm_label: String, + on_confirm: Message, +) -> Element<'a, Message> { + let backdrop = button(container(space()).width(Length::Fill).height(Length::Fill)) + .on_press(Message::CancelDelete) + .width(Length::Fill) + .height(Length::Fill) + .style(|_: &Theme, _| button::Style { + background: Some(Background::Color(Palette::BG_PRIMARY().scale_alpha(0.75))), + ..button::Style::default() + }); + + let card = container( + column![ + ui_text(format!("{} {}", theme::icons::DELETE, title), 20) + .font(colony_ui::ui_font_bold()) + .color(Palette::ERROR()), + ui_text(question, 14).color(Palette::TEXT_PRIMARY()), + ui_text(t("delete_undo_hint"), 12).color(Palette::TEXT_MUTED()), + row![ + button(ui_text(confirm_label, 14)) + .on_press(on_confirm) + .padding([10, 24]) + .style(theme::primary_btn_style(Palette::BTN_TRASH_HOVER())), + button(ui_text(t("action_cancel"), 14)) + .on_press(Message::CancelDelete) + .padding([10, 24]) + .style(theme::outline_btn_style(Palette::TEXT_SECONDARY())), + ] + .spacing(12), + ] + .spacing(12), + ) + .padding(24) + .max_width(450) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Palette::BG_CARD())), + border: Border { + radius: 12.0.into(), + color: Palette::ERROR().scale_alpha(0.6), + width: 2.0, + }, + ..container::Style::default() + }); + + stack![ + backdrop, + container(card) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill), + ] + .into() +} diff --git a/src/views/import.rs b/src/views/import.rs index 148c2ec..02707b6 100644 --- a/src/views/import.rs +++ b/src/views/import.rs @@ -1,48 +1,54 @@ -use iced::widget::{button, column, container, row, rule, space, text, text_input}; +use colony_ui::theme::Palette; +use colony_ui::{sz, ui_font}; +use iced::widget::{button, column, container, row, rule, space, text_input}; use iced::{Background, Border, Color, Element, Length, Theme}; use super::helpers::{platform_dot, separator, status_badge}; use crate::app::{Message, Spotter}; +use crate::i18n::{t, tf, tn}; use crate::messages::AuthMessage; -use crate::theme::{self, ViewTheme}; +use crate::models::Platform; +use crate::theme::{self, ui_text}; + +/// Where Steam hands out Web API keys. +const STEAM_KEY_PAGE: &str = "steamcommunity.com/dev/apikey"; pub fn view(app: &Spotter) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); - let accent = app.settings.accent_color.color(); + let accent = Palette::ACCENT(); let header = column![ - text(format!("{} Import Games", theme::icons::IMPORT)) - .size(24) - .font(theme::FONT_BOLD) - .color(Color::WHITE), - text("Import your game library from Steam, GOG, and more") - .size(12) - .color(vt.text_muted), + ui_text( + format!("{} {}", theme::icons::IMPORT, t("import_title")), + 24 + ) + .font(colony_ui::ui_font_bold()) + .color(Palette::TEXT_PRIMARY()), + ui_text(t("import_subtitle"), 12).color(Palette::TEXT_MUTED()), ] .spacing(4); // ── Import status banner ── - let status_section = import_status_banner(app, vt); + let status_section = import_status_banner(app); // ── Row 1: Steam | GOG ── - let steam_card = steam_setup_card(app, vt); - let gog_card = gog_setup_card(app, vt); + let steam_card = steam_setup_card(app); + let gog_card = gog_setup_card(app); // ── Row 2: Epic | Xbox ── - let epic_card = epic_setup_card(app, vt); + let epic_card = epic_setup_card(app); - let xbox_card = xbox_setup_card(app, vt); + let xbox_card = xbox_setup_card(app); // ── Row 3: PlayStation | Nintendo ── - let psn_card = psn_setup_card(app, vt); + let psn_card = psn_setup_card(app); - let nintendo_card = nintendo_info_card(vt); + let nintendo_card = nintendo_info_card(); // ── Help section ── - let help_card = help_section(accent, vt); + let help_card = help_section(accent); // Import history log - let history_section = import_history_card(app, vt); + let history_section = import_history_card(app); let content = column![ header, @@ -69,10 +75,15 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { // ── Status banner ── -fn import_status_banner<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { +fn import_status_banner<'a>(app: &'a Spotter) -> Element<'a, Message> { let any_importing = !app.importing.is_empty(); - if !any_importing && app.import_status.is_empty() { + let status = match &app.import_status { + Ok(text) if text.is_empty() => None, + Ok(text) => Some((text, false)), + Err(text) => Some((text, true)), + }; + if !any_importing && status.is_none() { return column![].into(); } @@ -80,28 +91,32 @@ fn import_status_banner<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mess if any_importing { let platforms: Vec<&String> = app.importing.iter().collect(); - let label = format!( - "Importing from {}...", - platforms - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") + let label = tf( + "import_from", + &[( + "platform", + &platforms + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "), + )], ); - let dots = match (std::time::SystemTime::now() + // The dots move between frames: an animation, so still without effects. + let phase = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() - / 500) - % 4 - { + / 500; + let dots = match phase % 4 { + _ if !colony_ui::effects_enabled() => "...", 0 => ".", 1 => "..", 2 => "...", _ => "", }; - let spinner = text(dots.to_string()).size(14).color(theme::ACCENT_GOLD); - let msg = text(label).size(14).color(theme::ACCENT_GOLD); + let spinner = ui_text(dots.to_string(), 14).color(Palette::WARNING()); + let msg = ui_text(label, 14).color(Palette::WARNING()); content = content.push( row![spinner, msg] .spacing(10) @@ -109,33 +124,29 @@ fn import_status_banner<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mess ); } - if !app.import_status.is_empty() && !any_importing { - let is_error = app.import_status.contains("error") || app.import_status.contains("Error"); + if let Some((text, is_error)) = status.filter(|_| !any_importing) { let (icon, color) = if is_error { - ("!", Color::from_rgb(1.0, 0.4, 0.4)) + (theme::icons::TIMES, Palette::ERROR()) } else { - ("OK", theme::SUCCESS) + (theme::icons::CHECK, Palette::SUCCESS()) }; - let badge = container(text(icon).size(11).color(Color::WHITE)) + let badge = container(ui_text(icon, 11).color(Palette::TEXT_PRIMARY())) .padding([2, 8]) .style(theme::pill_style( - Color { a: 0.2, ..color }, - Color { a: 0.4, ..color }, + color.scale_alpha(0.2), + color.scale_alpha(0.4), )); content = content.push( - row![badge, text(&app.import_status).size(13).color(color)] + row![badge, ui_text(text.as_str(), 13).color(color)] .spacing(10) .align_y(iced::Alignment::Center), ); } let (banner_bg, banner_border) = if any_importing { - ( - Color::from_rgb(0.12, 0.11, 0.06), - Color::from_rgb(0.25, 0.22, 0.08), - ) + (Palette::WARNING_BG(), Palette::WARNING().scale_alpha(0.4)) } else { - (vt.bg_card, vt.border) + (Palette::BG_CARD(), Palette::BORDER_SUBTLE()) }; container(content) @@ -147,8 +158,8 @@ fn import_status_banner<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mess // ── Steam setup card ── -fn steam_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let color = Color::from_rgb(0.4, 0.6, 0.9); +fn steam_setup_card(app: &Spotter) -> Element<'_, Message> { + let color = Platform::Steam.color(); let has_steam_id = !app.profile.steam_id.is_empty(); let has_api_key = !app.profile.steam_api_key.is_empty(); let ready = has_steam_id && has_api_key; @@ -156,16 +167,16 @@ fn steam_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { // Header row: platform dot + title + status pill let status_pill = if ready { - status_badge("Ready", theme::SUCCESS) + status_badge(t("import_ready"), Palette::SUCCESS()) } else if has_steam_id { - status_badge("API key needed", theme::ACCENT_GOLD) + status_badge(t("import_key_needed"), Palette::WARNING()) } else { - status_badge("Setup required", Color::from_rgb(0.8, 0.4, 0.3)) + status_badge(t("import_setup_needed"), Palette::ERROR()) }; let header_row = row![ platform_dot(color), - text("Steam").size(18).color(color), + ui_text(Platform::Steam.store_name(), 18).color(color), space::horizontal(), status_pill, ] @@ -173,57 +184,58 @@ fn steam_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { .align_y(iced::Alignment::Center); // Step 1: Login - let step1_label = step_label("1", "Login", color); + let step1_label = step_label("1", t("import_step_login"), color); let login_status: Element<'_, Message> = if has_steam_id { row![ - text("ID: ").size(12).color(theme::TEXT_MUTED), - text(&app.profile.steam_id).size(12).color(theme::SUCCESS), + ui_text(t("import_steam_id"), 12).color(Palette::TEXT_MUTED()), + ui_text(&app.profile.steam_id, 12).color(Palette::SUCCESS()), ] .spacing(4) .into() } else { - text("Not logged in") - .size(12) - .color(Color::from_rgb(0.6, 0.4, 0.35)) + ui_text(t("import_not_logged_in"), 12) + .color(Palette::TEXT_DIM()) .into() }; let login_btn = if app.steam_login_active { - button(text("Waiting...").size(12).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_waiting"), 12).color(Palette::WARNING())) .padding([6, 14]) .style(theme::loading_btn_style()) } else { - button(text("Login").size(12).color(theme::BG_DARKER)) + button(ui_text(t("import_login"), 12)) .on_press(AuthMessage::SteamLogin.into()) .padding([6, 14]) .style(theme::primary_btn_style(color)) }; // Step 2: API Key - let step2_label = step_label("2", "API Key", color); + let step2_label = step_label("2", t("import_step_api_key"), color); let api_key_input = column![ - text_input("Steam Web API key", &app.profile.steam_api_key) - .on_input(Message::UpdateSteamApiKey) - .padding(8) - .size(12), - text("steamcommunity.com/dev/apikey") - .size(10) - .color(theme::TEXT_DIM), + text_input( + t("import_steam_key_placeholder"), + &app.profile.steam_api_key + ) + .on_input(Message::UpdateSteamApiKey) + .padding(8) + .size(sz(12)) + .font(ui_font()), + ui_text(STEAM_KEY_PAGE, 10).color(Palette::TEXT_DIM()), ] .spacing(4); // Import button let import_btn = if ready && !steam_importing { - button(text("Import Steam").size(13).color(theme::BG_DARKER)) + button(ui_text(import_label(Platform::Steam), 13)) .on_press(Message::ImportSteam) .padding([8, 18]) - .style(theme::primary_btn_style(theme::SUCCESS)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else if steam_importing { - button(text("Importing...").size(13).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_importing"), 13).color(Palette::WARNING())) .padding([8, 18]) .style(theme::loading_btn_style()) } else { - button(text("Import Steam").size(13).color(theme::TEXT_HEADING)) + button(ui_text(import_label(Platform::Steam), 13).color(Palette::TEXT_DIMMER())) .padding([8, 18]) .style(theme::disabled_btn_style()) }; @@ -246,28 +258,28 @@ fn steam_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { container(card_content) .padding([14, 16]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, color)) + .style(theme::accent_card_style(color)) .into() } // ── GOG setup card ── -fn gog_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let color = Color::from_rgb(0.7, 0.5, 0.9); +fn gog_setup_card(app: &Spotter) -> Element<'_, Message> { + let color = Platform::Gog.color(); let has_token = !app.profile.gog_token.is_empty(); let gog_importing = app.importing.contains("GOG"); let status_pill = if has_token { - status_badge("Ready", theme::SUCCESS) + status_badge(t("import_ready"), Palette::SUCCESS()) } else if app.gog_login_active { - status_badge("Logging in...", theme::ACCENT_GOLD) + status_badge(t("import_logging_in"), Palette::WARNING()) } else { - status_badge("Setup required", Color::from_rgb(0.8, 0.4, 0.3)) + status_badge(t("import_setup_needed"), Palette::ERROR()) }; let header_row = row![ platform_dot(color), - text("GOG Galaxy").size(18).color(color), + ui_text(Platform::Gog.store_name(), 18).color(color), space::horizontal(), status_pill, ] @@ -275,27 +287,27 @@ fn gog_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { .align_y(iced::Alignment::Center); // Step 1: Login - let step1_label = step_label("1", "Login", color); + let step1_label = step_label("1", t("import_step_login"), color); let login_status: Element<'_, Message> = if has_token { - text("Logged in").size(12).color(theme::SUCCESS).into() + ui_text(t("import_logged_in"), 12) + .color(Palette::SUCCESS()) + .into() } else if app.gog_login_active { - text("Waiting for login...") - .size(12) - .color(theme::ACCENT_GOLD) + ui_text(t("import_waiting_login"), 12) + .color(Palette::WARNING()) .into() } else { - text("Not logged in") - .size(12) - .color(Color::from_rgb(0.6, 0.4, 0.35)) + ui_text(t("import_not_logged_in"), 12) + .color(Palette::TEXT_DIM()) .into() }; let login_btn = if app.gog_login_active { - button(text("Waiting...").size(12).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_waiting"), 12).color(Palette::WARNING())) .padding([6, 14]) .style(theme::loading_btn_style()) } else { - button(text("Login").size(12).color(theme::BG_DARKER)) + button(ui_text(t("import_login"), 12)) .on_press(AuthMessage::GogLogin.into()) .padding([6, 14]) .style(theme::primary_btn_style(color)) @@ -303,14 +315,14 @@ fn gog_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { // Step 2: Paste code let code_section: Element<'_, Message> = if app.gog_login_active || !has_token { - let step2_label = step_label("2", "Paste URL", color); + let step2_label = step_label("2", t("import_step_paste_address"), color); let submit_btn = if !app.gog_code_input.is_empty() { - button(text("Complete").size(12).color(theme::BG_DARKER)) + button(ui_text(t("import_finish_login"), 12)) .on_press(AuthMessage::GogSubmitCode.into()) .padding([6, 14]) - .style(theme::primary_btn_style(theme::SUCCESS)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else { - button(text("Complete").size(12).color(theme::TEXT_HEADING)) + button(ui_text(t("import_finish_login"), 12).color(Palette::TEXT_DIMMER())) .padding([6, 14]) .style(theme::disabled_btn_style()) }; @@ -318,13 +330,12 @@ fn gog_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { column![ separator(color), step2_label, - text("Copy the redirect URL after signing in") - .size(10) - .color(theme::TEXT_DIM), - text_input("Paste redirect URL here...", &app.gog_code_input,) + ui_text(t("import_gog_hint"), 10).color(Palette::TEXT_DIM()), + text_input(t("import_gog_placeholder"), &app.gog_code_input,) .on_input(|s| AuthMessage::GogCodeChanged(s).into()) .padding(8) - .size(12), + .size(sz(12)) + .font(ui_font()), submit_btn, ] .spacing(6) @@ -335,16 +346,16 @@ fn gog_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { // Import button let import_btn = if has_token && !gog_importing { - button(text("Import GOG").size(13).color(theme::BG_DARKER)) + button(ui_text(import_label(Platform::Gog), 13)) .on_press(Message::ImportGog) .padding([8, 18]) - .style(theme::primary_btn_style(theme::SUCCESS)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else if gog_importing { - button(text("Importing...").size(13).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_importing"), 13).color(Palette::WARNING())) .padding([8, 18]) .style(theme::loading_btn_style()) } else { - button(text("Import GOG").size(13).color(theme::TEXT_HEADING)) + button(ui_text(import_label(Platform::Gog), 13).color(Palette::TEXT_DIMMER())) .padding([8, 18]) .style(theme::disabled_btn_style()) }; @@ -365,30 +376,30 @@ fn gog_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { container(card_content) .padding([14, 16]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, color)) + .style(theme::accent_card_style(color)) .into() } // ── Epic Games setup card ── -fn epic_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let color = Color::from_rgb(0.9, 0.9, 0.3); +fn epic_setup_card(app: &Spotter) -> Element<'_, Message> { + let color = Platform::Epic.color(); let has_token = !app.profile.epic_token.is_empty(); let has_account = !app.profile.epic_account_id.is_empty(); let logged_in = has_token && has_account; let epic_importing = app.importing.contains("Epic"); let status_pill = if logged_in { - status_badge("Connected", theme::SUCCESS) + status_badge(t("import_connected"), Palette::SUCCESS()) } else if app.epic_login_active { - status_badge("Logging in...", theme::ACCENT_GOLD) + status_badge(t("import_logging_in"), Palette::WARNING()) } else { - status_badge("Local only", Color::from_rgb(0.7, 0.6, 0.3)) + status_badge(t("import_local_only"), Palette::WARNING()) }; let header_row = row![ platform_dot(color), - text("Epic Games").size(18).color(color), + ui_text(Platform::Epic.store_name(), 18).color(color), space::horizontal(), status_pill, ] @@ -396,7 +407,7 @@ fn epic_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { .align_y(iced::Alignment::Center); // Step 1: Login - let step1_label = step_label("1", "Login", color); + let step1_label = step_label("1", t("import_step_login"), color); let login_status: Element<'_, Message> = if logged_in { let name = if app.profile.epic_display_name.is_empty() { app.profile @@ -409,34 +420,32 @@ fn epic_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { app.profile.epic_display_name.clone() }; row![ - text("Account: ").size(12).color(theme::TEXT_MUTED), - text(name).size(12).color(theme::SUCCESS), + ui_text(t("import_account"), 12).color(Palette::TEXT_MUTED()), + ui_text(name, 12).color(Palette::SUCCESS()), ] .spacing(4) .into() } else if app.epic_login_active { - text("Waiting for login...") - .size(12) - .color(theme::ACCENT_GOLD) + ui_text(t("import_waiting_login"), 12) + .color(Palette::WARNING()) .into() } else { - text("Optional — without login, only installed games are detected") - .size(12) - .color(Color::from_rgb(0.6, 0.5, 0.35)) + ui_text(t("import_epic_optional"), 12) + .color(Palette::TEXT_DIM()) .into() }; let login_btn = if app.epic_login_active { - button(text("Waiting...").size(12).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_waiting"), 12).color(Palette::WARNING())) .padding([6, 14]) .style(theme::loading_btn_style()) } else if logged_in { - button(text("Re-login").size(12).color(theme::BG_DARKER)) + button(ui_text(t("import_login_again"), 12)) .on_press(AuthMessage::EpicLogin.into()) .padding([6, 14]) .style(theme::primary_btn_style(color)) } else { - button(text("Login").size(12).color(theme::BG_DARKER)) + button(ui_text(t("import_login"), 12)) .on_press(AuthMessage::EpicLogin.into()) .padding([6, 14]) .style(theme::primary_btn_style(color)) @@ -444,14 +453,14 @@ fn epic_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { // Step 2: Paste code (only shown when login is active or not logged in) let code_section: Element<'_, Message> = if app.epic_login_active || !logged_in { - let step2_label = step_label("2", "Paste Code", color); + let step2_label = step_label("2", t("import_step_paste_code"), color); let submit_btn = if !app.epic_code_input.is_empty() { - button(text("Complete").size(12).color(theme::BG_DARKER)) + button(ui_text(t("import_finish_login"), 12)) .on_press(AuthMessage::EpicSubmitCode.into()) .padding([6, 14]) - .style(theme::primary_btn_style(theme::SUCCESS)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else { - button(text("Complete").size(12).color(theme::TEXT_HEADING)) + button(ui_text(t("import_finish_login"), 12).color(Palette::TEXT_DIMMER())) .padding([6, 14]) .style(theme::disabled_btn_style()) }; @@ -459,16 +468,12 @@ fn epic_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { column![ separator(color), step2_label, - text("Copy the JSON response (or authorization code) from the browser page") - .size(10) - .color(theme::TEXT_DIM), - text_input( - "Paste JSON or authorization code here...", - &app.epic_code_input - ) - .on_input(|s| AuthMessage::EpicCodeChanged(s).into()) - .padding(8) - .size(12), + ui_text(t("import_epic_hint"), 10).color(Palette::TEXT_DIM()), + text_input(t("import_epic_placeholder"), &app.epic_code_input) + .on_input(|s| AuthMessage::EpicCodeChanged(s).into()) + .padding(8) + .size(sz(12)) + .font(ui_font()), submit_btn, ] .spacing(6) @@ -478,38 +483,36 @@ fn epic_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { }; // Import button - let import_label = if logged_in { - "Import Full Library" + let import_label = t(if logged_in { + "import_epic_full" } else { - "Import Local Games" - }; + "import_epic_local" + }); let import_btn = if !epic_importing { - button(text(import_label).size(13).color(theme::BG_DARKER)) + button(ui_text(import_label, 13)) .on_press(Message::ImportEpic) .padding([8, 18]) .style(theme::primary_btn_style(if logged_in { - theme::SUCCESS + Palette::BTN_SUCCESS() } else { color })) } else { - button(text("Importing...").size(13).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_importing"), 13).color(Palette::WARNING())) .padding([8, 18]) .style(theme::loading_btn_style()) }; - let import_note: Element<'_, Message> = if logged_in { - text("Full library import via Epic account (all owned games)") - .size(10) - .color(theme::TEXT_DIM) - .into() - } else { - text("Local scan only (installed games). Login for full library.") - .size(10) - .color(theme::TEXT_DIM) - .into() - }; + let import_note = ui_text( + t(if logged_in { + "import_epic_full_note" + } else { + "import_epic_local_note" + }), + 10, + ) + .color(Palette::TEXT_DIM()); let card_content = column![ header_row, @@ -528,29 +531,29 @@ fn epic_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { container(card_content) .padding([14, 16]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, color)) + .style(theme::accent_card_style(color)) .into() } // ── Xbox setup card ── -fn xbox_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let color = Color::from_rgb(0.2, 0.8, 0.2); +fn xbox_setup_card(app: &Spotter) -> Element<'_, Message> { + let color = Platform::Xbox.color(); let has_key = !app.profile.xbox_api_key.is_empty(); let has_tag = !app.profile.xbox_gamertag.is_empty(); let xbox_importing = app.importing.contains("Xbox"); let status_pill = if xbox_importing { - status_badge("Importing...", theme::ACCENT_GOLD) + status_badge(t("import_importing"), Palette::WARNING()) } else if has_key { - status_badge("Ready", theme::SUCCESS) + status_badge(t("import_ready"), Palette::SUCCESS()) } else { - status_badge("Setup required", Color::from_rgb(0.8, 0.4, 0.3)) + status_badge(t("import_setup_needed"), Palette::ERROR()) }; let header_row = row![ platform_dot(color), - text("Xbox").size(18).color(color), + ui_text(Platform::Xbox.store_name(), 18).color(color), space::horizontal(), status_pill, ] @@ -558,36 +561,34 @@ fn xbox_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { .align_y(iced::Alignment::Center); // Step 1: API Key - let step1_label = step_label("1", "API Key", color); + let step1_label = step_label("1", t("import_step_api_key"), color); let api_key_input = column![ - text_input( - "Enter your OpenXBL API key from xbl.io", - &app.profile.xbox_api_key - ) - .on_input(Message::UpdateXboxApiKey) - .padding(8) - .size(12), - button( - text("Get a free API key at xbl.io") - .size(10) - .color(theme::ACCENT_BLUE), - ) - .on_press(Message::OpenUrl("https://xbl.io".to_string())) - .padding(0) - .style(theme::transparent_btn_style()), + text_input(t("import_xbox_key_placeholder"), &app.profile.xbox_api_key) + .on_input(Message::UpdateXboxApiKey) + .padding(8) + .size(sz(12)) + .font(ui_font()), + button(ui_text(t("import_xbox_key_link"), 10).color(Palette::ACCENT()),) + .on_press(Message::OpenUrl("https://xbl.io".to_string())) + .padding(0) + .style(theme::transparent_btn_style()), ] .spacing(4); // Step 2: Gamertag (shown when API key is entered) let gamertag_section: Element<'_, Message> = if has_key || has_tag { - let step2_label = step_label("2", "Gamertag", color); + let step2_label = step_label("2", t("import_step_gamertag"), color); column![ separator(color), step2_label, - text_input("Your Xbox gamertag", &app.profile.xbox_gamertag) - .on_input(Message::UpdateXboxGamertag) - .padding(8) - .size(12), + text_input( + t("import_xbox_gamertag_placeholder"), + &app.profile.xbox_gamertag + ) + .on_input(Message::UpdateXboxGamertag) + .padding(8) + .size(sz(12)) + .font(ui_font()), ] .spacing(6) .into() @@ -597,16 +598,16 @@ fn xbox_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { // Import button let import_btn = if has_key && !xbox_importing { - button(text("Import Xbox").size(13).color(theme::BG_DARKER)) + button(ui_text(import_label(Platform::Xbox), 13)) .on_press(Message::ImportXbox) .padding([8, 18]) - .style(theme::primary_btn_style(theme::SUCCESS)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else if xbox_importing { - button(text("Importing...").size(13).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_importing"), 13).color(Palette::WARNING())) .padding([8, 18]) .style(theme::loading_btn_style()) } else { - button(text("Import Xbox").size(13).color(theme::TEXT_HEADING)) + button(ui_text(import_label(Platform::Xbox), 13).color(Palette::TEXT_DIMMER())) .padding([8, 18]) .style(theme::disabled_btn_style()) }; @@ -625,28 +626,28 @@ fn xbox_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { container(card_content) .padding([14, 16]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, color)) + .style(theme::accent_card_style(color)) .into() } // ── PlayStation setup card ── -fn psn_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let color = Color::from_rgb(0.2, 0.4, 0.9); +fn psn_setup_card(app: &Spotter) -> Element<'_, Message> { + let color = Platform::PlayStation.color(); let has_token = !app.profile.psn_npsso.is_empty(); let psn_importing = app.importing.contains("PlayStation"); let status_pill = if psn_importing { - status_badge("Importing...", theme::ACCENT_GOLD) + status_badge(t("import_importing"), Palette::WARNING()) } else if has_token { - status_badge("Ready", theme::SUCCESS) + status_badge(t("import_ready"), Palette::SUCCESS()) } else { - status_badge("Setup required", Color::from_rgb(0.8, 0.4, 0.3)) + status_badge(t("import_setup_needed"), Palette::ERROR()) }; let header_row = row![ platform_dot(color), - text("PlayStation").size(18).color(color), + ui_text(Platform::PlayStation.store_name(), 18).color(color), space::horizontal(), status_pill, ] @@ -654,46 +655,36 @@ fn psn_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { .align_y(iced::Alignment::Center); // Step 1: NPSSO Token - let step1_label = step_label("1", "NPSSO Token", color); + let step1_label = step_label("1", t("import_step_npsso"), color); let npsso_input = column![ - text_input( - "Get from ca.account.sony.com/api/v1/ssocookie", - &app.profile.psn_npsso, - ) - .on_input(Message::UpdatePsnNpsso) - .padding(8) - .size(12), - button( - text("Get token from ca.account.sony.com") - .size(10) - .color(theme::ACCENT_BLUE), - ) - .on_press(Message::OpenUrl( - "https://ca.account.sony.com/api/v1/ssocookie".to_string(), - )) - .padding(0) - .style(theme::transparent_btn_style()), + text_input(t("import_psn_placeholder"), &app.profile.psn_npsso) + .on_input(Message::UpdatePsnNpsso) + .padding(8) + .size(sz(12)) + .font(ui_font()), + button(ui_text(t("import_psn_link"), 10).color(Palette::ACCENT()),) + .on_press(Message::OpenUrl( + "https://ca.account.sony.com/api/v1/ssocookie".to_string(), + )) + .padding(0) + .style(theme::transparent_btn_style()), ] .spacing(4); // Import button let import_btn = if has_token && !psn_importing { - button(text("Import PlayStation").size(13).color(theme::BG_DARKER)) + button(ui_text(import_label(Platform::PlayStation), 13)) .on_press(Message::ImportPlayStation) .padding([8, 18]) - .style(theme::primary_btn_style(theme::SUCCESS)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else if psn_importing { - button(text("Importing...").size(13).color(theme::ACCENT_GOLD)) + button(ui_text(t("import_importing"), 13).color(Palette::WARNING())) .padding([8, 18]) .style(theme::loading_btn_style()) } else { - button( - text("Import PlayStation") - .size(13) - .color(theme::TEXT_HEADING), - ) - .padding([8, 18]) - .style(theme::disabled_btn_style()) + button(ui_text(import_label(Platform::PlayStation), 13).color(Palette::TEXT_DIMMER())) + .padding([8, 18]) + .style(theme::disabled_btn_style()) }; let card_content = column![ @@ -709,69 +700,62 @@ fn psn_setup_card(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { container(card_content) .padding([14, 16]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, color)) + .style(theme::accent_card_style(color)) .into() } // ── Nintendo info card (no importer available) ── -fn nintendo_info_card(vt: ViewTheme) -> Element<'static, Message> { - let color = Color::from_rgb(0.9, 0.2, 0.2); +fn nintendo_info_card() -> Element<'static, Message> { + let color = Platform::Nintendo.color(); let header_row = row![ platform_dot(color), - text("Nintendo").size(18).color(color), + ui_text(Platform::Nintendo.store_name(), 18).color(color), space::horizontal(), - status_badge("Manual only", Color::from_rgb(0.6, 0.5, 0.3)), + status_badge(t("import_manual_only"), Palette::TEXT_MUTED()), ] .spacing(10) .align_y(iced::Alignment::Center); let card_content = column![ header_row, - text("Nintendo does not provide a public API for game libraries.") - .size(12) - .color(vt.text_muted), - text("You can add Nintendo games manually via the Add Game page.") - .size(12) - .color(vt.text_muted), + ui_text(t("import_nintendo_no_api"), 12).color(Palette::TEXT_MUTED()), + ui_text(t("import_nintendo_manual"), 12).color(Palette::TEXT_MUTED()), ] .spacing(10); container(card_content) .padding([14, 18]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, color)) + .style(theme::accent_card_style(color)) .into() } // ── Import history card ── -fn import_history_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { +fn import_history_card<'a>(app: &'a Spotter) -> Element<'a, Message> { if app.import_history.is_empty() { return column![].into(); } - let accent = app.settings.accent_color.color(); + let accent = Palette::ACCENT(); let mut log_col = column![].spacing(4); for entry in app.import_history.iter().rev().take(10) { - let is_error = entry.contains("ERROR"); - let color = if is_error { - Color::from_rgb(1.0, 0.4, 0.4) - } else { - theme::SUCCESS + let (text, color) = match entry { + Ok(text) => (text, Palette::SUCCESS()), + Err(text) => (text, Palette::ERROR()), }; - log_col = log_col.push(text(entry.as_str()).size(12).color(color)); + log_col = log_col.push(ui_text(text.as_str(), 12).color(color)); } let card_content = column![ row![ - text("Import Log").size(16).color(accent), + ui_text(t("import_log"), 16).color(accent), space::horizontal(), - text(format!("{} entries", app.import_history.len())) - .size(11) - .color(vt.text_dim), + ui_text(tn("import_log_entries", app.import_history.len(), &[]), 11) + .color(Palette::TEXT_DIM()), ] .align_y(iced::Alignment::Center), separator(accent), @@ -782,59 +766,38 @@ fn import_history_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Messa container(card_content) .padding([14, 18]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, accent)) + .style(theme::accent_card_style(accent)) .into() } // ── Help section ── -fn help_section<'a>(accent: Color, vt: ViewTheme) -> Element<'a, Message> { - let tips = column![ - tip_row( - "Steam", - "Login to get your ID, then enter your API key from steamcommunity.com/dev/apikey", - Color::from_rgb(0.4, 0.6, 0.9) - ), - tip_row( - "GOG", - "Click Login, sign in, paste the redirect URL back", - Color::from_rgb(0.7, 0.5, 0.9) - ), - tip_row( - "Epic", - "Login to import full library, or scan locally (installed only)", - Color::from_rgb(0.9, 0.9, 0.3) - ), - tip_row( - "Xbox", - "Get a free API key from xbl.io, enter it above, then import", - Color::from_rgb(0.2, 0.8, 0.2) - ), - tip_row( - "PSN", - "Get NPSSO token from ca.account.sony.com, enter it above", - Color::from_rgb(0.2, 0.4, 0.9) - ), - tip_row( - "Nintendo", - "No API available — add games manually via Add Game", - Color::from_rgb(0.9, 0.2, 0.2) - ), - ] +fn help_section<'a>(accent: Color) -> Element<'a, Message> { + let tips = column( + [ + (Platform::Steam, "import_tip_steam"), + (Platform::Gog, "import_tip_gog"), + (Platform::Epic, "import_tip_epic"), + (Platform::Xbox, "import_tip_xbox"), + (Platform::PlayStation, "import_tip_playstation"), + (Platform::Nintendo, "import_tip_nintendo"), + ] + .map(|(platform, tip)| tip_row(platform, t(tip))), + ) .spacing(8); let notes = column![ - note_row("Re-importing updates existing games and adds new ones (no duplicates)"), - note_row("Cover images are downloaded automatically after import"), - note_row("You can export/import JSON backups from the Profile page"), + note_row(t("import_note_reimport")), + note_row(t("import_note_covers")), + note_row(t("import_note_backups")), ] .spacing(4); let card_content = column![ row![ - text("Setup Guide").size(16).color(accent), + ui_text(t("import_guide"), 16).color(accent), space::horizontal(), - text("Quick Reference").size(11).color(vt.text_dim), + ui_text(t("import_guide_subtitle"), 11).color(Palette::TEXT_DIM()), ] .align_y(iced::Alignment::Center), rule::horizontal(1), @@ -844,8 +807,8 @@ fn help_section<'a>(accent: Color, vt: ViewTheme) -> Element<'a, Message> { ] .spacing(12); - let bg = vt.bg_card; - let border = vt.border; + let bg = Palette::BG_CARD(); + let border = Palette::BORDER_SUBTLE(); container(card_content) .padding([16, 20]) .width(Length::Fill) @@ -855,14 +818,19 @@ fn help_section<'a>(accent: Color, vt: ViewTheme) -> Element<'a, Message> { // ── Shared helpers ── +/// "Import Steam", on the button that starts an import. +fn import_label(platform: Platform) -> String { + tf("import_platform", &[("platform", &platform)]) +} + fn step_label<'a>(num: &'a str, label: &'a str, color: Color) -> Element<'a, Message> { - let num_badge = container(text(num).size(11).color(color)) + let num_badge = container(ui_text(num, 11).color(color)) .width(22) .height(22) .center_x(22) .center_y(22) .style(move |_: &Theme| container::Style { - background: Some(Background::Color(Color { a: 0.15, ..color })), + background: Some(Background::Color(color.scale_alpha(0.15))), border: Border { radius: 11.0.into(), ..Border::default() @@ -870,21 +838,18 @@ fn step_label<'a>(num: &'a str, label: &'a str, color: Color) -> Element<'a, Mes ..container::Style::default() }); - row![ - num_badge, - text(label).size(13).color(Color { a: 0.7, ..color }), - ] - .spacing(8) - .align_y(iced::Alignment::Center) - .into() + row![num_badge, ui_text(label, 13).color(color.scale_alpha(0.7)),] + .spacing(8) + .align_y(iced::Alignment::Center) + .into() } -fn tip_row<'a>(platform: &'a str, tip: &'a str, color: Color) -> Element<'a, Message> { +fn tip_row<'a>(platform: Platform, tip: &'a str) -> Element<'a, Message> { row![ - container(text(platform).size(11).color(color)) - .width(55) + container(ui_text(platform.to_string(), 11).color(platform.color())) + .width(colony_ui::sz(80)) .padding([2, 0]), - text(tip).size(12).color(theme::TEXT_SECONDARY), + ui_text(tip, 12).color(Palette::TEXT_SECONDARY()), ] .spacing(10) .align_y(iced::Alignment::Start) @@ -893,8 +858,8 @@ fn tip_row<'a>(platform: &'a str, tip: &'a str, color: Color) -> Element<'a, Mes fn note_row<'a>(note: &'a str) -> Element<'a, Message> { row![ - text("*").size(11).color(theme::TEXT_DIM).width(14), - text(note).size(11).color(theme::TEXT_DIM), + ui_text("*", 11).color(Palette::TEXT_DIM()).width(14), + ui_text(note, 11).color(Palette::TEXT_DIM()), ] .spacing(4) .into() diff --git a/src/views/library.rs b/src/views/library.rs index 9f6ed46..e883174 100644 --- a/src/views/library.rs +++ b/src/views/library.rs @@ -1,155 +1,86 @@ +use colony_ui::theme::Palette; +use colony_ui::{sz, ui_font}; use iced::widget::{ - button, column, container, progress_bar, row, rule, scrollable, space, text, text_input, + button, column, container, progress_bar, row, rule, scrollable, space, text_input, }; -use iced::{Background, Border, Color, Element, Length, Theme}; +use iced::{Background, Border, Element, Length, Theme}; use std::collections::HashSet; use crate::app::{Message, Screen, Spotter}; -use crate::models::{GameStatus, Platform, SortOrder}; -use crate::theme::{self, ViewTheme}; +use crate::i18n::{percent, t, tf, tn}; +use crate::models::{GameStatus, Platform, Preferences, SortOrder}; +use crate::theme::{self, ui_text}; pub fn view(app: &Spotter) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); - let header = column![ - text(format!("{} Game Library", theme::icons::LIBRARY)) - .size(24) - .font(theme::FONT_BOLD) - .color(Color::WHITE), - text("Track your games across all platforms") - .size(12) - .color(vt.text_muted), + ui_text( + format!("{} {}", theme::icons::LIBRARY, t("library_title")), + 24 + ) + .font(colony_ui::ui_font_bold()) + .color(Palette::TEXT_PRIMARY()), + ui_text(t("library_subtitle"), 12).color(Palette::TEXT_MUTED()), ] .spacing(4); - let search = text_input( - "Search games by title, genre, or tags...", - &app.search_query, - ) - .id(iced::widget::Id::new("library_search")) - .on_input(Message::SearchChanged) - .padding(10) - .size(14); + let search = text_input(t("library_search"), &app.search_query) + .id(iced::widget::Id::new("library_search")) + .on_input(Message::SearchChanged) + .padding(10) + .size(sz(14)) + .font(ui_font()); - let bp = vt.btn_padding; + let bp = app.preferences.button_padding(); // Status filter chips (with icons) - let s_playing = format!("{} Playing", GameStatus::Playing.icon()); - let s_completed = format!("{} Completed", GameStatus::Completed.icon()); - let s_unplayed = format!("{} Unplayed", GameStatus::Unplayed.icon()); - let s_dropped = format!("{} Dropped", GameStatus::Dropped.icon()); - let s_wishlist = format!("{} Wishlist", GameStatus::Wishlist.icon()); - - let status_buttons = row![ - filter_chip("All", app.filter_status.is_none(), None, bp), - filter_chip( - &s_playing, - app.filter_status == Some(GameStatus::Playing), - Some(GameStatus::Playing), - bp - ), + let status_buttons = row(std::iter::once(filter_chip( + t("filter_all_statuses"), + app.filter_status.is_none(), + None, + bp, + )) + .chain(GameStatus::ALL.map(|status| { filter_chip( - &s_completed, - app.filter_status == Some(GameStatus::Completed), - Some(GameStatus::Completed), - bp - ), - filter_chip( - &s_unplayed, - app.filter_status == Some(GameStatus::Unplayed), - Some(GameStatus::Unplayed), - bp - ), - filter_chip( - &s_dropped, - app.filter_status == Some(GameStatus::Dropped), - Some(GameStatus::Dropped), - bp - ), - filter_chip( - &s_wishlist, - app.filter_status == Some(GameStatus::Wishlist), - Some(GameStatus::Wishlist), - bp - ), - ] + &format!("{} {}", status.icon(), status.label()), + app.filter_status == Some(status), + Some(status), + bp, + ) + }))) .spacing(6); // Platform filter chips (with icons) - let p_steam = format!("{} Steam", Platform::Steam.icon()); - let p_gog = format!("{} GOG", Platform::Gog.icon()); - let p_epic = format!("{} Epic", Platform::Epic.icon()); - let p_ps = format!("{} PlayStation", Platform::PlayStation.icon()); - let p_xbox = format!("{} Xbox", Platform::Xbox.icon()); - let p_nin = format!("{} Nintendo", Platform::Nintendo.icon()); - - let platform_buttons = row![ - platform_chip("All", app.filter_platform.is_none(), None, bp), - platform_chip( - &p_steam, - app.filter_platform == Some(Platform::Steam), - Some(Platform::Steam), - bp - ), - platform_chip( - &p_gog, - app.filter_platform == Some(Platform::Gog), - Some(Platform::Gog), - bp - ), - platform_chip( - &p_epic, - app.filter_platform == Some(Platform::Epic), - Some(Platform::Epic), - bp - ), + let platform_buttons = row(std::iter::once(platform_chip( + t("filter_all_platforms"), + app.filter_platform.is_none(), + None, + bp, + )) + .chain(Platform::ALL.map(|platform| { platform_chip( - &p_ps, - app.filter_platform == Some(Platform::PlayStation), - Some(Platform::PlayStation), - bp - ), - platform_chip( - &p_xbox, - app.filter_platform == Some(Platform::Xbox), - Some(Platform::Xbox), - bp - ), - platform_chip( - &p_nin, - app.filter_platform == Some(Platform::Nintendo), - Some(Platform::Nintendo), - bp - ), - ] + &format!("{} {}", platform.icon(), platform), + app.filter_platform == Some(platform), + Some(platform), + bp, + ) + }))) .spacing(6); // Sort buttons - let sort_row = row![ - text("Sort:").size(12).color(vt.text_muted), - sort_chip("A-Z", SortOrder::TitleAsc, app.sort_order, vt.bg_card), - sort_chip("Z-A", SortOrder::TitleDesc, app.sort_order, vt.bg_card), - sort_chip( - "Playtime", - SortOrder::PlaytimeDesc, - app.sort_order, - vt.bg_card - ), - sort_chip("Rating", SortOrder::RatingDesc, app.sort_order, vt.bg_card), - sort_chip( - "Recent", - SortOrder::LastPlayedDesc, - app.sort_order, - vt.bg_card - ), - sort_chip( - &format!("{} Favs", theme::icons::STAR), - SortOrder::FavoritesFirst, - app.sort_order, - vt.bg_card - ), - ] + let sort_row = row(std::iter::once::>( + ui_text(t("library_sort"), 12) + .color(Palette::TEXT_MUTED()) + .into(), + ) + .chain(SortOrder::ALL.map(|order| { + let label = if order == SortOrder::FavoritesFirst { + format!("{} {}", theme::icons::STAR, order.label()) + } else { + order.label().to_owned() + }; + sort_chip(&label, order, app.sort_order) + }))) .spacing(6) .align_y(iced::Alignment::Center); @@ -160,32 +91,20 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { .collect(); // Bulk mode toggle + count - let bulk_toggle = button( - text(if app.bulk_mode { - "Exit Select" - } else { - "Select" - }) - .size(12) - .color(if app.bulk_mode { - theme::ACCENT_GOLD + let bulk_toggle = button(ui_text( + t(if app.bulk_mode { + "library_select_done" } else { - theme::TEXT_MUTED + "library_select" }), - ) + 12, + )) .on_press(Message::ToggleBulkMode) .padding([4, 10]) - .style(theme::chip_style( - app.bulk_mode, - theme::TEXT_MUTED, - theme::ACCENT_GOLD, - 8.0, - )); + .style(theme::chip_style(app.bulk_mode, Palette::WARNING(), 8.0)); let count_text = row![ - text(format!("{} game(s)", filtered.len())) - .size(12) - .color(vt.text_muted), + ui_text(tn("games_count", filtered.len(), &[]), 12).color(Palette::TEXT_MUTED()), space::horizontal(), bulk_toggle, ] @@ -195,145 +114,106 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { // Bulk action bar (shown when items are selected) let bulk_bar: Element = if app.bulk_mode && !app.bulk_selected.is_empty() { let sel_count = app.bulk_selected.len(); - let accent = app.settings.accent_color.color(); - let bar_bg = vt.bg_card; - let bar_border = vt.border; + let set_status = |status: GameStatus| { + bar_button( + format!("{} {}", status.icon(), status.label()), + status.color(), + Message::BulkSetStatus(status), + ) + }; container( row![ - text(format!("{} selected", sel_count)) - .size(13) - .color(accent), - button(text("All").size(11).color(theme::ACCENT_BLUE)) - .on_press(Message::BulkSelectAll) - .padding([4, 10]) - .style(theme::outline_btn_style(theme::ACCENT_BLUE, bar_bg)), - button(text("None").size(11).color(theme::TEXT_MUTED)) - .on_press(Message::BulkDeselectAll) - .padding([4, 10]) - .style(theme::outline_btn_style(theme::TEXT_MUTED, bar_bg)), + ui_text(tn("library_selected", sel_count, &[]), 13).color(Palette::ACCENT()), + bar_button( + t("library_select_all").to_owned(), + Palette::ACCENT(), + Message::BulkSelectAll + ), + bar_button( + t("library_select_none").to_owned(), + Palette::TEXT_MUTED(), + Message::BulkDeselectAll + ), space::horizontal(), - button( - text(format!("{} Playing", GameStatus::Playing.icon())) - .size(11) - .color(GameStatus::Playing.color()), - ) - .on_press(Message::BulkSetStatus(GameStatus::Playing)) - .padding([4, 10]) - .style(theme::outline_btn_style( - GameStatus::Playing.color(), - bar_bg - )), - button( - text(format!("{} Completed", GameStatus::Completed.icon())) - .size(11) - .color(GameStatus::Completed.color()), - ) - .on_press(Message::BulkSetStatus(GameStatus::Completed)) - .padding([4, 10]) - .style(theme::outline_btn_style( - GameStatus::Completed.color(), - bar_bg, - )), - button( - text(format!("{} Dropped", GameStatus::Dropped.icon())) - .size(11) - .color(GameStatus::Dropped.color()), - ) - .on_press(Message::BulkSetStatus(GameStatus::Dropped)) - .padding([4, 10]) - .style(theme::outline_btn_style( - GameStatus::Dropped.color(), - bar_bg - )), - button( - text("Delete") - .size(11) - .color(Color::from_rgb(1.0, 0.4, 0.4)) - ) - .on_press(Message::BulkDelete) - .padding([4, 10]) - .style(theme::outline_btn_style( - Color::from_rgb(1.0, 0.4, 0.4), - bar_bg, - )), + set_status(GameStatus::Playing), + set_status(GameStatus::Completed), + set_status(GameStatus::Dropped), + bar_button( + t("action_delete").to_owned(), + Palette::ERROR(), + Message::BulkDelete + ), ] .spacing(6) .align_y(iced::Alignment::Center), ) .padding([8, 12]) .width(Length::Fill) - .style(theme::card_style(bar_bg, bar_border, 8.0)) + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 8.0, + )) .into() } else { column![].into() }; - let show_covers = app.settings.show_covers_in_list; - let compact = app.settings.compact_list; - let favorites = &app.settings.favorites; + let prefs = &app.preferences; let bulk_mode = app.bulk_mode; let game_list: Element = if filtered.is_empty() { // Build a helpful empty-state showing which filters are active - let mut empty_col = column![text("No games match your filters") - .size(18) - .color(vt.text_muted),] - .spacing(8) - .align_x(iced::Alignment::Center); + let mut empty_col = column![ui_text(t("library_empty"), 18).color(Palette::TEXT_MUTED()),] + .spacing(8) + .align_x(iced::Alignment::Center); // Describe active filters let mut active_parts: Vec = Vec::new(); if !app.search_query.is_empty() { - active_parts.push(format!("search \"{}\"", app.search_query)); + active_parts.push(tf("library_filter_search", &[("query", &app.search_query)])); } - if let Some(ref s) = app.filter_status { - active_parts.push(format!("status = {}", s)); + if let Some(status) = app.filter_status { + active_parts.push(tf("library_filter_status", &[("status", &status.label())])); } - if let Some(ref p) = app.filter_platform { - active_parts.push(format!("platform = {}", p)); + if let Some(platform) = app.filter_platform { + active_parts.push(tf("library_filter_platform", &[("platform", &platform)])); } if !active_parts.is_empty() { empty_col = empty_col.push( - text(format!("Active filters: {}", active_parts.join(", "))) - .size(13) - .color(vt.text_dim), + ui_text( + tf( + "library_active_filters", + &[("filters", &active_parts.join(", "))], + ), + 13, + ) + .color(Palette::TEXT_DIM()), ); } // Suggest clearing filters let mut hint_row = row![].spacing(6).align_y(iced::Alignment::Center); - hint_row = hint_row.push( - text("Try broadening your search or") - .size(12) - .color(vt.text_dim), - ); + hint_row = hint_row.push(ui_text(t("library_try_broader"), 12).color(Palette::TEXT_DIM())); if app.filter_status.is_some() { hint_row = hint_row.push( - button( - text("clear status filter") - .size(12) - .color(theme::ACCENT_BLUE), - ) - .on_press(Message::FilterStatus(None)) - .padding([4, 8]) - .style(theme::transparent_btn_style()), + button(ui_text(t("library_clear_status"), 12).color(Palette::ACCENT())) + .on_press(Message::FilterStatus(None)) + .padding([4, 8]) + .style(theme::transparent_btn_style()), ); } if app.filter_platform.is_some() { hint_row = hint_row.push( - button( - text("clear platform filter") - .size(12) - .color(theme::ACCENT_BLUE), - ) - .on_press(Message::FilterPlatform(None)) - .padding([4, 8]) - .style(theme::transparent_btn_style()), + button(ui_text(t("library_clear_platform"), 12).color(Palette::ACCENT())) + .on_press(Message::FilterPlatform(None)) + .padding([4, 8]) + .style(theme::transparent_btn_style()), ); } if !app.search_query.is_empty() { hint_row = hint_row.push( - button(text("clear search").size(12).color(theme::ACCENT_BLUE)) + button(ui_text(t("library_clear_search"), 12).color(Palette::ACCENT())) .on_press(Message::SearchChanged(String::new())) .padding([4, 8]) .style(theme::transparent_btn_style()), @@ -347,10 +227,10 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { + (app.filter_platform.is_some() as u8); if active_count > 1 { empty_col = empty_col.push( - button(text("Clear all filters").size(12).color(theme::ACCENT_BLUE)) + button(ui_text(t("library_clear_all"), 12)) .on_press(Message::ClearAllFilters) .padding([6, 14]) - .style(theme::outline_btn_style(theme::ACCENT_BLUE, vt.bg_card)), + .style(theme::outline_btn_style(Palette::ACCENT())), ); } @@ -359,7 +239,6 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { .center_x(Length::Fill) .into() } else { - let show_descriptions = app.settings.show_game_descriptions; let bulk_selected = &app.bulk_selected; let cover_cache = &app.cover_cache; let total_filtered = filtered.len(); @@ -370,11 +249,7 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { .map(|(_index, game)| { game_card( game, - show_covers, - compact, - show_descriptions, - vt, - favorites.contains(&game.id.unwrap_or(-1)), + prefs, bulk_mode, game.id.is_some_and(|id| bulk_selected.contains(&id)), cover_cache, @@ -382,7 +257,7 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { }) .collect(); - let spacing = if compact { 4 } else { 8 }; + let spacing = if prefs.compact_list { 4 } else { 8 }; let mut game_column = column![].spacing(spacing).width(Length::Fill); for card in cards { game_column = game_column.push(card); @@ -391,14 +266,13 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { let remaining = total_filtered - limit; game_column = game_column.push( container( - button( - text(format!("Show more ({} remaining)", remaining)) - .size(13) - .color(theme::ACCENT_BLUE), - ) + button(ui_text( + tf("library_show_more", &[("count", &remaining)]), + 13, + )) .on_press(Message::ShowMoreCards) .padding([8, 20]) - .style(theme::outline_btn_style(theme::ACCENT_BLUE, vt.bg_card)), + .style(theme::outline_btn_style(Palette::ACCENT())), ) .padding([12, 0]) .center_x(Length::Fill), @@ -412,32 +286,30 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { // Onboarding banner for first-time users let onboarding: Element = if app.first_launch { - let accent = app.settings.accent_color.color(); - let import_btn = button(text("Import Games").size(12).color(theme::BG_DARKER)) + let accent = Palette::ACCENT(); + let import_btn = button(ui_text(t("import_title"), 12)) .on_press(Message::NavigateTo(crate::app::Screen::Import)) .padding([6, 16]) - .style(theme::primary_btn_style(theme::SUCCESS)); - let add_btn = button(text("Add Manually").size(12).color(accent)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())); + let add_btn = button(ui_text(t("onboarding_add"), 12)) .on_press(Message::NavigateTo(crate::app::Screen::AddGame)) .padding([6, 16]) - .style(theme::outline_btn_style(accent, vt.bg_card)); - let dismiss_btn = button(text("Dismiss").size(12).color(theme::TEXT_MUTED)) + .style(theme::outline_btn_style(accent)); + let dismiss_btn = button(ui_text(t("action_dismiss"), 12).color(Palette::TEXT_MUTED())) .on_press(Message::DismissOnboarding) .padding([6, 16]) .style(theme::transparent_btn_style()); let onboard_card = container( column![ - text("Welcome to Spotter!").size(18).color(accent), - text("Import from Steam, GOG, Epic, Xbox, PlayStation — or add games manually.") - .size(13) - .color(vt.text_secondary), + ui_text(t("onboarding_title"), 18).color(accent), + ui_text(t("onboarding_text"), 13).color(Palette::TEXT_SECONDARY()), row![import_btn, add_btn, dismiss_btn].spacing(8), ] .spacing(8), ) .padding([14, 18]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, accent)); + .style(theme::accent_card_style(accent)); onboard_card.into() } else { column![].into() @@ -471,45 +343,11 @@ fn filter_chip( status: Option, pad: u16, ) -> Element<'static, Message> { - let label = label.to_string(); - let color = if is_active { - theme::ACCENT_BLUE - } else { - theme::TEXT_MUTED - }; - - button(text(label).size(12).color(color)) + let color = status.map_or_else(Palette::ACCENT, |s| s.color()); + button(ui_text(label.to_string(), 12)) .on_press(Message::FilterStatus(status)) .padding([pad, 12]) - .style(move |_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let base_bg = if is_active { - Color::from_rgb(0.15, 0.2, 0.3) - } else { - theme::BG_BUTTON - }; - let bg = if hover { - crate::theme::lighten(base_bg, 0.04) - } else { - base_bg - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 12.0.into(), - color: if is_active { - Color::from_rgb(0.3, 0.4, 0.6) - } else if hover { - Color::from_rgb(0.2, 0.2, 0.28) - } else { - Color::TRANSPARENT - }, - width: 1.0, - }, - ..button::Style::default() - } - }) + .style(theme::chip_style(is_active, color, 12.0)) .into() } @@ -519,105 +357,38 @@ fn platform_chip( platform: Option, pad: u16, ) -> Element<'static, Message> { - let label = label.to_string(); - let color = if is_active { - Color::from_rgb(0.8, 0.6, 1.0) - } else { - theme::TEXT_MUTED - }; - - button(text(label).size(11).color(color)) + let color = platform.map_or_else(Palette::ACCENT, |p| p.color()); + button(ui_text(label.to_string(), 11)) .on_press(Message::FilterPlatform(platform)) .padding([pad.saturating_sub(2), 10]) - .style(move |_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let base_bg = if is_active { - Color::from_rgb(0.18, 0.12, 0.28) - } else { - theme::BG_BUTTON - }; - let bg = if hover { - crate::theme::lighten(base_bg, 0.04) - } else { - base_bg - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 10.0.into(), - color: if is_active { - Color::from_rgb(0.4, 0.3, 0.6) - } else if hover { - Color::from_rgb(0.2, 0.2, 0.28) - } else { - Color::TRANSPARENT - }, - width: 1.0, - }, - ..button::Style::default() - } - }) + .style(theme::chip_style(is_active, color, 10.0)) .into() } -fn sort_chip( - label: &str, - target: SortOrder, - current: SortOrder, - bg_card: Color, -) -> Element<'static, Message> { - let label = label.to_string(); - let is_active = current == target; - let color = if is_active { - theme::ACCENT_GOLD - } else { - theme::TEXT_DIM - }; - - button(text(label).size(11).color(color)) +fn sort_chip(label: &str, target: SortOrder, current: SortOrder) -> Element<'static, Message> { + button(ui_text(label.to_string(), 11)) .on_press(Message::SetSortOrder(target)) .padding([4, 8]) - .style(move |_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let base_bg = if is_active { - Color::from_rgb(0.18, 0.16, 0.08) - } else { - bg_card - }; - let bg = if hover { - crate::theme::lighten(base_bg, 0.04) - } else { - base_bg - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 6.0.into(), - color: if is_active { - Color::from_rgb(0.5, 0.42, 0.15) - } else if hover { - Color::from_rgb(0.2, 0.2, 0.28) - } else { - Color::TRANSPARENT - }, - width: 1.0, - }, - ..button::Style::default() - } - }) + .style(theme::chip_style( + current == target, + Palette::WARNING(), + 6.0, + )) + .into() +} + +/// A small outlined button of the bulk action bar. +fn bar_button(label: String, color: iced::Color, msg: Message) -> Element<'static, Message> { + button(ui_text(label, 11)) + .on_press(msg) + .padding([4, 10]) + .style(theme::outline_btn_style(color)) .into() } -#[allow(clippy::too_many_arguments)] fn game_card<'a>( game: &'a crate::models::Game, - show_covers: bool, - compact: bool, - show_descriptions: bool, - vt: ViewTheme, - is_favorite: bool, + prefs: &Preferences, bulk_mode: bool, is_selected: bool, cover_cache: &HashSet, @@ -629,14 +400,14 @@ fn game_card<'a>( Some(id) => id, None => return iced::widget::text("...").into(), }; - - let bg_card = vt.bg_card; - let border_color = vt.border; + let is_favorite = prefs.favorites.contains(&game_id); + let compact = prefs.compact_list; // Cover image thumbnail (respects show_covers_in_list setting) - let cover_element: Option> = if show_covers { - let cover_path = crate::images::cover_path(&game.title); - if cover_cache.contains(&game.title) { + let cover_element: Option> = if prefs.show_covers_in_list { + let cover_path = + crate::images::cover_path(&game.title).filter(|_| cover_cache.contains(&game.title)); + if let Some(cover_path) = cover_path { Some( container( iced::widget::image(iced::widget::image::Handle::from_path(&cover_path)) @@ -649,23 +420,18 @@ fn game_card<'a>( } else { Some( container( - text(game.title.chars().next().unwrap_or('?').to_string()) - .size(22) - .color(theme::TEXT_HEADING), + ui_text(game.title.chars().next().unwrap_or('?').to_string(), 22) + .color(Palette::TEXT_DIMMER()), ) .width(120) .height(57) .center_x(120) .center_y(57) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg_card)), - border: Border { - radius: 4.0.into(), - color: border_color, - width: 1.0, - }, - ..container::Style::default() - }) + .style(theme::card_style( + Palette::BG_PRIMARY(), + Palette::BORDER_SUBTLE(), + 4.0, + )) .into(), ) } @@ -678,113 +444,122 @@ fn game_card<'a>( if bulk_mode { let check_label = if is_selected { "[x]" } else { "[ ]" }; let check_color = if is_selected { - theme::ACCENT_BLUE + Palette::ACCENT() } else { - theme::TEXT_DIM + Palette::TEXT_DIM() }; - title_items.push(text(check_label).size(14).color(check_color).into()); + title_items.push(ui_text(check_label, 14).color(check_color).into()); } // Favorite star if is_favorite { title_items.push( - text(theme::icons::STAR) - .size(14) - .color(theme::ACCENT_GOLD) + ui_text(theme::icons::STAR, 14) + .color(Palette::WARNING()) .into(), ); } // Status label (respects show_status_labels setting) - if vt.show_status_labels { + if prefs.show_status_labels { title_items.push( - text(format!("[{}]", game.status)) - .size(11) + ui_text(format!("[{}]", game.status.label()), 11) .color(status_color) .into(), ); } else { // Show a small color dot instead title_items.push( - container(text("").size(1)) + container(space()) .width(8) .height(8) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(status_color)), - border: Border { - radius: 4.0.into(), - ..Border::default() - }, - ..container::Style::default() - }) + .style(theme::dot_style(status_color, 8.0)) .into(), ); } - title_items.push(text(&game.title).size(16).color(Color::WHITE).into()); + title_items.push( + ui_text(&game.title, 16) + .color(Palette::TEXT_PRIMARY()) + .into(), + ); title_items.push(space::horizontal().into()); title_items.push( - text(format!("{} {}", game.platform.icon(), game.platform)) - .size(12) + ui_text(format!("{} {}", game.platform.icon(), game.platform), 12) .color(platform_color) .into(), ); let title_row = row(title_items).spacing(8).align_y(iced::Alignment::Center); - let mut info_items = row![text(&game.genre).size(12).color(vt.text_muted),].spacing(0); + let mut info_items = row![ui_text(&game.genre, 12).color(Palette::TEXT_MUTED()),].spacing(0); if !game.release_date.is_empty() { info_items = info_items.push( - text(format!( - " · {}", - vt.date_format.format_date(&game.release_date) - )) - .size(12) - .color(vt.text_dim), + ui_text( + format!(" · {}", prefs.date_format.format_date(&game.release_date)), + 12, + ) + .color(Palette::TEXT_DIM()), ); } if let Some(pct) = game.review_percent { let review_color = if pct >= 70 { - theme::SUCCESS + Palette::SUCCESS() } else if pct >= 40 { - Color::from_rgb(0.9, 0.7, 0.2) + Palette::WARNING() } else { - Color::from_rgb(0.9, 0.3, 0.3) + Palette::ERROR() }; info_items = info_items.push( - text(format!(" · {}% positive", pct)) - .size(12) - .color(review_color), + ui_text( + format!( + " · {}", + tf("library_review", &[("percent", &percent(pct as f32, 0))]) + ), + 12, + ) + .color(review_color), ); } info_items = info_items.push( - text(format!( - " · Last: {}", - vt.date_format.format_date(&game.last_played) - )) - .size(12) - .color(vt.text_dim), + ui_text( + format!( + " · {}", + tf( + "library_last_played", + &[("date", &prefs.date_format.format_date(&game.last_played))] + ) + ), + 12, + ) + .color(Palette::TEXT_DIM()), ); let info_row = info_items; - let playtime_text = text(format!("Playtime: {}", game.playtime_display())) - .size(12) - .color(vt.text_light); + let playtime_text = ui_text( + tf("library_playtime", &[("time", &game.playtime_display())]), + 12, + ) + .color(Palette::TEXT_PRIMARY()); let stats_row = if game.achievements_total > 0 { let pct = game.achievement_percent(); row![ playtime_text, space::horizontal(), - text(format!( - "{}/{} ({:.0}%)", - game.achievements_unlocked, game.achievements_total, pct - )) - .size(12) - .color(vt.text_light), + ui_text( + format!( + "{}/{} ({})", + game.achievements_unlocked, + game.achievements_total, + percent(pct, 0) + ), + 12 + ) + .color(Palette::TEXT_PRIMARY()), container(progress_bar(0.0..=100.0, pct).girth(6)).width(120), ] .spacing(8) @@ -793,7 +568,7 @@ fn game_card<'a>( row![ playtime_text, space::horizontal(), - text("No achievements").size(12).color(vt.text_dim), + ui_text(t("library_no_achievements"), 12).color(Palette::TEXT_DIM()), ] .spacing(8) .align_y(iced::Alignment::Center) @@ -803,24 +578,22 @@ fn game_card<'a>( Some(r) => { let filled = theme::icons::STAR.repeat(r as usize); let empty = theme::icons::STAR_O.repeat(10_usize.saturating_sub(r as usize)); - text(format!("{}{} {}/10", filled, empty, r)) - .size(11) - .color(theme::ACCENT_GOLD) + ui_text(format!("{}{} {}/10", filled, empty, r), 11).color(Palette::WARNING()) } - None => text("Not rated").size(11).color(vt.text_dim), + None => ui_text(t("rating_none"), 11).color(Palette::TEXT_DIM()), }; let text_spacing = if compact { 3 } else { 6 }; let mut text_info = column![title_row, info_row, stats_row, rating_text,].spacing(text_spacing); - if show_descriptions && !game.description.is_empty() { + if prefs.show_game_descriptions && !game.description.is_empty() { let desc = if game.description.chars().count() > 120 { let truncated: String = game.description.chars().take(120).collect(); format!("{}...", truncated) } else { game.description.clone() }; - text_info = text_info.push(text(desc).size(11).color(vt.text_dim)); + text_info = text_info.push(ui_text(desc, 11).color(Palette::TEXT_DIM())); } let card_content = if let Some(cover) = cover_element { @@ -831,44 +604,37 @@ fn game_card<'a>( row![text_info].spacing(0).align_y(iced::Alignment::Center) }; - let padding = if compact { 8 } else { 14 }; - let card = container(card_content) - .padding(padding) - .width(Length::Fill) - .style(theme::card_style(bg_card, border_color, 8.0)); - let card_message = if bulk_mode { Message::ToggleBulkSelect(game_id) } else { Message::NavigateTo(Screen::GameDetail(game_id)) }; - let selected_border = is_selected && bulk_mode; - button(card) + let selected = is_selected && bulk_mode; + button(card_content) .on_press(card_message) - .padding(0) + .padding(if compact { 8 } else { 14 }) .width(Length::Fill) .style(move |_: &Theme, status| { let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); + let background = if selected { + Palette::BG_SELECTED() + } else if hover { + Palette::BG_CARD_HOVER() + } else { + Palette::BG_CARD() + }; button::Style { - background: if selected_border { - Some(Background::Color(Color::from_rgba(0.6, 0.8, 1.0, 0.05))) - } else if hover { - Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.03))) - } else { - None - }, - border: if selected_border { - Border { - radius: 8.0.into(), - color: Color::from_rgba(0.6, 0.8, 1.0, 0.3), - width: 1.0, - } - } else { - Border { - radius: 8.0.into(), - ..Border::default() - } + background: Some(Background::Color(background)), + text_color: Palette::TEXT_PRIMARY(), + border: Border { + radius: 8.0.into(), + color: if selected { + Palette::ACCENT() + } else { + Palette::BORDER_SUBTLE() + }, + width: 1.0, }, ..button::Style::default() } diff --git a/src/views/mod.rs b/src/views/mod.rs index 5726646..174c2f1 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -3,7 +3,7 @@ pub mod detail; pub mod helpers; pub mod import; pub mod library; +pub mod preferences; pub mod profile; -pub mod settings; pub mod sidebar; pub mod stats; diff --git a/src/views/preferences.rs b/src/views/preferences.rs new file mode 100644 index 0000000..ceac429 --- /dev/null +++ b/src/views/preferences.rs @@ -0,0 +1,404 @@ +//! The Preferences page, built from the shared Colony kit: General, +//! Appearance and Accessibility first, then Spotter's own Library and Imports. +//! It replaces the content area; the sidebar stays. + +use colony_ui::widgets::{ + category_heading, category_list, collapsible_section, dropdown, functional_toggle, + preferences_page, section_description, setting_row, PreferencesView, STANDARD_CATEGORIES, +}; +use colony_ui::Typography; +use iced::widget::{column, Column}; +use iced::{Element, Length}; + +use crate::app::{Message, Spotter}; +use crate::i18n::{t, LANGUAGES}; +use crate::messages::PreferencesMessage; +use crate::models::*; + +/// Spotter's own categories, after the three every Colony program opens with. +const OWN_CATEGORIES: [&str; 2] = ["preferences_cat_library", "preferences_cat_imports"]; + +pub fn view(app: &Spotter) -> Element<'_, Message> { + let typo = Typography::current(); + let labels = STANDARD_CATEGORIES + .iter() + .chain(&OWN_CATEGORIES) + .map(|key| t(key)); + let categories = category_list(&typo, labels, app.preferences_category, |index| { + PreferencesMessage::Category(index).into() + }); + + let kit = PreferencesView::new( + typo, + &app.preferences.standard, + &app.preference_sections, + |change| PreferencesMessage::Standard(change).into(), + |key| PreferencesMessage::ToggleSection(key).into(), + ); + let page = Page { app, typo }; + let content: Element<'_, Message> = match app.preferences_category { + 0 => kit + .general() + .push(page.language()) + .push(page.startup()) + .push(page.notifications()) + .push(page.dates()) + .into(), + 1 => category_heading( + &typo, + t("settings_cat_appearance"), + t("settings_appearance_desc"), + ) + .push(kit.theme_section()) + .push(kit.colors_section()) + .push(kit.typography_section()) + .push(page.layout()) + .push(kit.effects_section()) + .push(kit.preview_section()) + .into(), + 2 => kit + .accessibility_heading() + .push(kit.vision_section()) + .push(page.color_cues()) + .push(kit.motion_section()) + .push(page.navigation()) + .push(kit.reading_section()) + .into(), + 3 => category_heading( + &typo, + t("preferences_cat_library"), + t("preferences_library_desc"), + ) + .push(page.new_games()) + .push(page.sorting()) + .push(page.game_details()) + .push(page.deleting()) + .into(), + _ => category_heading( + &typo, + t("preferences_cat_imports"), + t("preferences_imports_desc"), + ) + .push(page.covers()) + .into(), + }; + + preferences_page( + &typo, + categories, + content, + PreferencesMessage::Toggle.into(), + ) +} + +/// Spotter's own sections, drawn like the kit's. +struct Page<'a> { + app: &'a Spotter, + typo: Typography, +} + +impl<'a> Page<'a> { + fn prefs(&self) -> &'a Preferences { + &self.app.preferences + } + + // ── General ── + + fn language(&self) -> Element<'a, Message> { + let options = std::iter::once((None, t("preferences_language_system").to_owned())).chain( + LANGUAGES + .iter() + .map(|(tag, name)| (Some((*tag).to_owned()), (*name).to_owned())), + ); + self.section( + "language", + "preferences_section_language", + "preferences_language_desc", + vec![self.choice( + "preferences_language", + "preferences_language_choice_desc", + options, + &self.prefs().language, + PreferencesMessage::Language, + )], + ) + } + + fn startup(&self) -> Element<'a, Message> { + self.section( + "startup", + "preferences_section_startup", + "preferences_startup_desc", + vec![self.choice( + "preferences_start_screen", + "preferences_start_screen_desc", + StartScreen::ALL.map(|s| (s, s.label().to_owned())), + &self.prefs().start_screen, + PreferencesMessage::StartScreen, + )], + ) + } + + fn notifications(&self) -> Element<'a, Message> { + let prefs = self.prefs(); + self.section( + "notifications", + "preferences_section_notifications", + "preferences_notifications_desc", + vec![ + self.toggle( + "preferences_show_notifications", + "preferences_show_notifications_desc", + prefs.notifications_enabled, + PreferencesMessage::Notifications, + ), + self.choice( + "preferences_toast_duration", + "preferences_toast_duration_desc", + ToastDuration::ALL.map(|d| (d, d.label().to_owned())), + &prefs.toast_duration, + PreferencesMessage::ToastDuration, + ), + ], + ) + } + + fn dates(&self) -> Element<'a, Message> { + self.section( + "dates", + "preferences_section_dates", + "preferences_dates_desc", + vec![self.choice( + "preferences_date_format", + "preferences_date_format_desc", + DateFormat::ALL.map(|f| (f, f.label().to_owned())), + &self.prefs().date_format, + PreferencesMessage::DateFormat, + )], + ) + } + + // ── Appearance ── + + fn layout(&self) -> Element<'a, Message> { + let prefs = self.prefs(); + let widths = [ + (180, t("preferences_sidebar_narrow").to_owned()), + (220, t("preferences_sidebar_normal").to_owned()), + (280, t("preferences_sidebar_wide").to_owned()), + ]; + self.section( + "layout", + "preferences_section_layout", + "preferences_layout_desc", + vec![ + self.choice( + "preferences_sidebar_width", + "preferences_sidebar_width_desc", + widths, + &prefs.sidebar_width, + PreferencesMessage::SidebarWidth, + ), + self.toggle( + "preferences_compact_list", + "preferences_compact_list_desc", + prefs.compact_list, + PreferencesMessage::CompactList, + ), + self.toggle( + "preferences_show_covers", + "preferences_show_covers_desc", + prefs.show_covers_in_list, + PreferencesMessage::ShowCovers, + ), + ], + ) + } + + // ── Accessibility ── + + fn color_cues(&self) -> Element<'a, Message> { + self.section( + "color_cues", + "preferences_section_color_cues", + "preferences_color_cues_desc", + vec![self.toggle( + "preferences_status_labels", + "preferences_status_labels_desc", + self.prefs().show_status_labels, + PreferencesMessage::ShowStatusLabels, + )], + ) + } + + fn navigation(&self) -> Element<'a, Message> { + self.section( + "navigation", + "preferences_section_navigation", + "preferences_navigation_desc", + vec![self.toggle( + "preferences_large_targets", + "preferences_large_targets_desc", + self.prefs().large_click_targets, + PreferencesMessage::LargeTargets, + )], + ) + } + + // ── Library ── + + fn new_games(&self) -> Element<'a, Message> { + let prefs = self.prefs(); + self.section( + "new_games", + "preferences_section_new_games", + "preferences_new_games_desc", + vec![ + self.choice( + "preferences_default_status", + "preferences_preselected_desc", + GameStatus::ALL.map(|s| (s, s.label().to_owned())), + &prefs.default_status, + PreferencesMessage::DefaultStatus, + ), + self.choice( + "preferences_default_platform", + "preferences_preselected_desc", + Platform::ALL.map(|p| (p, p.to_string())), + &prefs.default_platform, + PreferencesMessage::DefaultPlatform, + ), + ], + ) + } + + fn sorting(&self) -> Element<'a, Message> { + let prefs = self.prefs(); + self.section( + "sorting", + "preferences_section_sorting", + "preferences_sorting_desc", + vec![ + self.choice( + "preferences_sort_order", + "preferences_sort_order_desc", + SortOrder::ALL.map(|s| (s, s.label().to_owned())), + &prefs.default_sort_order, + PreferencesMessage::DefaultSortOrder, + ), + self.toggle( + "preferences_remember_filters", + "preferences_remember_filters_desc", + prefs.remember_filters, + PreferencesMessage::RememberFilters, + ), + ], + ) + } + + fn game_details(&self) -> Element<'a, Message> { + let prefs = self.prefs(); + self.section( + "game_details", + "preferences_section_game_details", + "preferences_game_details_desc", + vec![ + self.toggle( + "preferences_show_descriptions", + "preferences_show_descriptions_desc", + prefs.show_game_descriptions, + PreferencesMessage::ShowDescriptions, + ), + self.choice( + "preferences_achievements_display", + "preferences_achievements_display_desc", + AchievementsDisplay::ALL.map(|d| (d, d.label().to_owned())), + &prefs.achievements_display, + PreferencesMessage::AchievementsDisplay, + ), + ], + ) + } + + fn deleting(&self) -> Element<'a, Message> { + self.section( + "deleting", + "preferences_section_deleting", + "preferences_deleting_desc", + vec![self.toggle( + "preferences_confirm_delete", + "preferences_confirm_delete_desc", + self.prefs().confirm_before_delete, + PreferencesMessage::ConfirmDelete, + )], + ) + } + + // ── Imports ── + + fn covers(&self) -> Element<'a, Message> { + self.section( + "covers", + "preferences_section_covers", + "preferences_covers_desc", + vec![self.toggle( + "preferences_auto_covers", + "preferences_auto_covers_desc", + self.prefs().download_covers_auto, + PreferencesMessage::DownloadCoversAuto, + )], + ) + } + + // ── Building blocks ── + + /// A collapsible section: its title, what its settings do, then them. + fn section( + &self, + key: &'static str, + title: &str, + description: &str, + controls: Vec>, + ) -> Element<'a, Message> { + collapsible_section( + &self.typo, + t(title), + self.app.preference_sections.contains(key), + PreferencesMessage::ToggleSection(key).into(), + column![ + section_description(&self.typo, t(description)), + Column::with_children(controls).width(Length::Fill), + ] + .into(), + ) + } + + fn toggle( + &self, + title: &str, + description: &str, + on: bool, + msg: PreferencesMessage, + ) -> Element<'a, Message> { + functional_toggle(&self.typo, t(title), t(description), on, msg.into()) + } + + /// A setting picked from a drop-down list. + fn choice( + &self, + title: &str, + description: &str, + options: impl IntoIterator, + selected: &T, + msg: impl Fn(T) -> PreferencesMessage + 'a, + ) -> Element<'a, Message> { + setting_row( + &self.typo, + t(title), + t(description), + dropdown(&self.typo, options, selected, move |value| { + msg(value).into() + }), + ) + } +} diff --git a/src/views/profile.rs b/src/views/profile.rs index 57f3027..44b7644 100644 --- a/src/views/profile.rs +++ b/src/views/profile.rs @@ -1,37 +1,42 @@ -use iced::widget::{button, column, container, row, rule, space, text, text_input}; +use colony_ui::theme::Palette; +use colony_ui::{sz, ui_font}; +use iced::widget::{button, column, container, row, rule, space, text_input}; use iced::{Background, Border, Color, Element, Length, Theme}; use super::helpers::{platform_dot, separator}; use crate::app::{Message, Spotter}; +use crate::i18n::{decimal, t, tf, tn}; use crate::models::{GameStatus, Platform}; -use crate::theme::{self, ViewTheme}; -pub fn view(app: &Spotter) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); +/// The backup formats, by name. +const JSON: &str = "JSON"; +const CSV: &str = "CSV"; +use crate::theme::{self, ui_text}; +pub fn view(app: &Spotter) -> Element<'_, Message> { let header = column![ - text(format!("{} User Profile", theme::icons::PROFILE)) - .size(24) - .font(theme::FONT_BOLD) - .color(Color::WHITE), - text("Manage your account and data") - .size(12) - .color(vt.text_muted), + ui_text( + format!("{} {}", theme::icons::PROFILE, t("nav_profile")), + 24 + ) + .font(colony_ui::ui_font_bold()) + .color(Palette::TEXT_PRIMARY()), + ui_text(t("profile_subtitle"), 12).color(Palette::TEXT_MUTED()), ] .spacing(4); // ── Profile overview card ── - let profile_card = profile_overview_card(app, vt); + let profile_card = profile_overview_card(app); // ── Platform note cards (credentials are managed on Import page) ── - let steam_note = platform_note_card("Steam", Platform::Steam.color(), vt); - let gog_note = platform_note_card("GOG Galaxy", Platform::Gog.color(), vt); - let epic_note = platform_note_card("Epic Games", Platform::Epic.color(), vt); - let xbox_note = platform_note_card("Xbox", Platform::Xbox.color(), vt); - let psn_note = platform_note_card("PlayStation", Platform::PlayStation.color(), vt); + let steam_note = platform_note_card(Platform::Steam); + let gog_note = platform_note_card(Platform::Gog); + let epic_note = platform_note_card(Platform::Epic); + let xbox_note = platform_note_card(Platform::Xbox); + let psn_note = platform_note_card(Platform::PlayStation); // ── Data & export card ── - let data_card = data_storage_card(app, vt); + let data_card = data_storage_card(app); let content = column![ header, @@ -39,11 +44,8 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { profile_card, row![steam_note, gog_note, epic_note].spacing(12), row![xbox_note, psn_note].spacing(12), - crate::keyring::is_unavailable().then(|| { - text("No system keyring available: platform tokens are stored unencrypted in the database.") - .size(12) - .color(theme::ACCENT_GOLD) - }), + crate::keyring::is_unavailable() + .then(|| { ui_text(t("profile_no_keyring"), 12).color(Palette::WARNING()) }), data_card, ] .spacing(16) @@ -55,8 +57,8 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { // ── Profile overview card ── -fn profile_overview_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { - let accent = app.settings.accent_color.color(); +fn profile_overview_card<'a>(app: &'a Spotter) -> Element<'a, Message> { + let accent = Palette::ACCENT(); let total_playtime: u32 = app.games.iter().map(|g| g.playtime_minutes).sum(); let completed = app @@ -87,16 +89,16 @@ fn profile_overview_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mes .to_uppercase() .to_string(); - let avatar = container(text(initial).size(38).color(accent)) + let avatar = container(ui_text(initial, 38).color(accent)) .width(80) .height(80) .center_x(80) .center_y(80) .style(move |_: &Theme| container::Style { - background: Some(Background::Color(Color { a: 0.12, ..accent })), + background: Some(Background::Color(accent.scale_alpha(0.12))), border: Border { radius: 40.0.into(), - color: Color { a: 0.3, ..accent }, + color: accent.scale_alpha(0.3), width: 2.0, }, ..container::Style::default() @@ -104,45 +106,65 @@ fn profile_overview_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mes // Username input inline let username_section = column![ - text(&app.profile.username).size(24).color(Color::WHITE), - text(format!( - "Member since {}", - vt.date_format.format_date(&app.profile.member_since) - )) - .size(12) - .color(theme::TEXT_MUTED), + ui_text(&app.profile.username, 24).color(Palette::TEXT_PRIMARY()), + ui_text( + tf( + "profile_member_since", + &[( + "date", + &app.preferences + .date_format + .format_date(&app.profile.member_since) + )] + ), + 12 + ) + .color(Palette::TEXT_MUTED()), ] .spacing(4); // Stats row let stats = row![ - stat_badge("Games", format!("{}", app.games.len()), theme::ACCENT_BLUE), - stat_badge("Playtime", format_playtime(total_playtime), theme::SUCCESS), - stat_badge("Completed", format!("{}", completed), theme::COMPLETED_BLUE), stat_badge( - "Avg Rating", - format!("{:.1}", avg_rating), - theme::ACCENT_GOLD + t("stats_games"), + format!("{}", app.games.len()), + Palette::ACCENT() + ), + stat_badge( + t("detail_playtime"), + format_playtime(total_playtime), + Palette::SUCCESS() + ), + stat_badge( + t("profile_completed"), + format!("{}", completed), + GameStatus::Completed.color() + ), + stat_badge( + t("stats_average_rating"), + decimal(avg_rating, 1), + Palette::WARNING() ), ] .spacing(8); // Username edit let username_input = column![ - text("Username").size(12).color(vt.text_muted), - text_input("Enter your username", &app.profile.username) + ui_text(t("profile_username"), 12).color(Palette::TEXT_MUTED()), + text_input(t("profile_username_placeholder"), &app.profile.username) .on_input(Message::UpdateUsername) .padding(10) - .size(13), + .size(sz(13)) + .font(ui_font()), ] .spacing(4); let save_btn = if app.profile_just_saved { - button(text("Saved!").size(14).color(theme::BG_DARKER)) + button(ui_text(t("profile_saved"), 14)) .padding([10, 24]) - .style(theme::primary_btn_style(theme::SUCCESS)) + .style(theme::primary_btn_style(Palette::BTN_SUCCESS())) } else { - button(text("Save Profile").size(14).color(theme::BG_DARKER)) + button(ui_text(t("profile_save"), 14)) .on_press(Message::SaveProfile) .padding([10, 24]) .style(theme::primary_btn_style(accent)) @@ -161,18 +183,23 @@ fn profile_overview_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mes container(card_content) .padding([16, 20]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, accent)) + .style(theme::accent_card_style(accent)) .into() } // ── Platform note card (credentials managed on Import page) ── -fn platform_note_card(name: &str, color: Color, vt: ViewTheme) -> Element<'static, Message> { +fn platform_note_card(platform: Platform) -> Element<'static, Message> { let card_content = row![ - platform_dot(color), - text(format!("{} — managed on Import page", name)) - .size(12) - .color(vt.text_muted), + platform_dot(platform.color()), + ui_text( + tf( + "profile_platform_note", + &[("platform", &platform.store_name())] + ), + 12 + ) + .color(Palette::TEXT_MUTED()), ] .spacing(10) .align_y(iced::Alignment::Center); @@ -180,83 +207,92 @@ fn platform_note_card(name: &str, color: Color, vt: ViewTheme) -> Element<'stati container(card_content) .padding([10, 16]) .width(Length::Fill) - .style(theme::card_style(vt.bg_card, vt.border, 10.0)) + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 10.0, + )) .into() } // ── Data storage card ── -fn data_storage_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { - let accent = app.settings.accent_color.color(); - let base_dir = crate::db::base_dir(); +fn data_storage_card<'a>(app: &'a Spotter) -> Element<'a, Message> { + let accent = Palette::ACCENT(); let header_row = row![ - text("Data & Export").size(18).color(accent), + ui_text(t("profile_data"), 18).color(accent), space::horizontal(), - text(format!("{} games stored", app.games.len())) - .size(12) - .color(vt.text_muted), + ui_text(tn("profile_games_stored", app.games.len(), &[]), 12).color(Palette::TEXT_MUTED()), ] .align_y(iced::Alignment::Center); - let db_path = crate::db::db_path(); - let covers_path = crate::db::covers_dir(); - let exports_path = crate::db::exports_dir(); - - let paths_section = column![ - data_row("Base", &base_dir.to_string_lossy(), vt), - data_row("Database", &db_path.to_string_lossy(), vt), - data_row("Covers", &covers_path.to_string_lossy(), vt), - data_row("Exports", &exports_path.to_string_lossy(), vt), - ] - .spacing(4); + // The paths in use, resolved at startup (see `paths::layout`). + let layout = crate::paths::layout(); + let paths_section: Element<'a, Message> = match &layout { + Ok(layout) => column![ + data_row(t("settings_title"), &layout.preferences.to_string_lossy()), + data_row(t("profile_database"), &layout.db.to_string_lossy()), + data_row(t("profile_exports"), &layout.exports.to_string_lossy()), + data_row(t("profile_covers"), &layout.covers.to_string_lossy()), + ] + .spacing(4) + .into(), + Err(e) => ui_text(e.clone(), 11).color(Palette::TEXT_DIM()).into(), + }; // Export/import buttons let export_json_btn = button( row![ - text("JSON").size(12).color(theme::ACCENT_BLUE), - text(format!("{} Export", theme::icons::EXPORT)) - .size(12) - .color(vt.text_light), + ui_text(JSON, 12).color(Palette::ACCENT()), + ui_text( + format!("{} {}", theme::icons::EXPORT, t("action_export")), + 12 + ) + .color(Palette::TEXT_PRIMARY()), ] .spacing(6), ) .on_press(Message::ExportData) .padding([8, 16]) - .style(theme::outline_btn_style(theme::ACCENT_BLUE, vt.bg_card)); + .style(theme::outline_btn_style(Palette::ACCENT())); let export_csv_btn = button( row![ - text("CSV").size(12).color(theme::ACCENT_GOLD), - text(format!("{} Export", theme::icons::EXPORT)) - .size(12) - .color(vt.text_light), + ui_text(CSV, 12).color(Palette::WARNING()), + ui_text( + format!("{} {}", theme::icons::EXPORT, t("action_export")), + 12 + ) + .color(Palette::TEXT_PRIMARY()), ] .spacing(6), ) .on_press(Message::ExportCsv) .padding([8, 16]) - .style(theme::outline_btn_style(theme::ACCENT_GOLD, vt.bg_card)); + .style(theme::outline_btn_style(Palette::WARNING())); let import_btn = button( row![ - text("JSON").size(12).color(theme::SUCCESS), - text(format!("{} Import", theme::icons::IMPORT)) - .size(12) - .color(vt.text_light), + ui_text(JSON, 12).color(Palette::SUCCESS()), + ui_text( + format!("{} {}", theme::icons::IMPORT, t("action_import")), + 12 + ) + .color(Palette::TEXT_PRIMARY()), ] .spacing(6), ) .on_press(Message::ImportJson) .padding([8, 16]) - .style(theme::outline_btn_style(theme::SUCCESS, vt.bg_card)); + .style(theme::outline_btn_style(Palette::SUCCESS())); - let backup_path = exports_path.join("spotter_export.json"); - let backup_note = if backup_path.exists() { - "Backup file found. Import will merge with your current library." + let backup_found = layout.is_ok_and(|l| l.exports.join("spotter_export.json").exists()); + let backup_note = t(if backup_found { + "profile_backup_found" } else { - "No backup file found. Export first to create one." - }; + "profile_backup_missing" + }); let card_content = column![ header_row, @@ -264,26 +300,26 @@ fn data_storage_card<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message paths_section, separator(accent), row![export_json_btn, export_csv_btn, import_btn].spacing(8), - text(backup_note).size(11).color(vt.text_dim), + ui_text(backup_note, 11).color(Palette::TEXT_DIM()), ] .spacing(10); container(card_content) .padding([16, 20]) .width(Length::Fill) - .style(theme::accent_card_style(vt.bg_card, accent)) + .style(theme::accent_card_style(accent)) .into() } // ── Shared helpers ── fn stat_badge<'a>(label: &'a str, value: String, color: Color) -> Element<'a, Message> { - let bg = Color { a: 0.1, ..color }; - let border_color = Color { a: 0.2, ..color }; + let bg = color.scale_alpha(0.1); + let border_color = color.scale_alpha(0.2); container( column![ - text(value).size(15).color(color), - text(label).size(10).color(theme::TEXT_DIM), + ui_text(value, 15).color(color), + ui_text(label, 10).color(Palette::TEXT_DIM()), ] .spacing(2) .align_x(iced::Alignment::Center), @@ -293,10 +329,12 @@ fn stat_badge<'a>(label: &'a str, value: String, color: Color) -> Element<'a, Me .into() } -fn data_row<'a>(label: &'a str, value: &str, vt: ViewTheme) -> Element<'a, Message> { +fn data_row<'a>(label: &'a str, value: &str) -> Element<'a, Message> { row![ - text(label).size(12).color(vt.text_muted).width(80), - text(value.to_string()).size(11).color(vt.text_dim), + ui_text(label, 12) + .color(Palette::TEXT_MUTED()) + .width(colony_ui::sz(130)), + ui_text(value.to_string(), 11).color(Palette::TEXT_DIM()), ] .spacing(8) .into() diff --git a/src/views/settings.rs b/src/views/settings.rs deleted file mode 100644 index b0b286c..0000000 --- a/src/views/settings.rs +++ /dev/null @@ -1,592 +0,0 @@ -use iced::widget::{button, column, container, row, rule, text, toggler}; -use iced::{Background, Border, Color, Element, Length, Theme}; - -use crate::app::{Message, Spotter}; -use crate::messages::SettingsMessage; -use crate::models::*; -use crate::theme::{self, ViewTheme}; - -pub fn view(app: &Spotter) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); - - let saved_indicator: Element<'_, Message> = if app.settings_just_saved { - text("Saved!").size(12).color(theme::SUCCESS).into() - } else { - text("").size(1).into() - }; - let header = column![ - row![ - text(format!("{} Settings", theme::icons::SETTINGS)) - .size(24) - .font(theme::FONT_BOLD) - .color(Color::WHITE), - saved_indicator, - ] - .spacing(10) - .align_y(iced::Alignment::Center), - text("Customize your Spotter experience") - .size(12) - .color(vt.text_muted), - ] - .spacing(4); - - let accent = app.settings.accent_color.color(); - - // Section tabs - let tab_bar = row![ - section_tab( - "General", - SettingsSection::General, - app.settings_section, - accent - ), - section_tab( - "Appearances", - SettingsSection::Appearances, - app.settings_section, - accent - ), - section_tab( - "Accessibility", - SettingsSection::Accessibility, - app.settings_section, - accent - ), - ] - .spacing(4); - - let section_content = match app.settings_section { - SettingsSection::General => general_section(app, vt), - SettingsSection::Appearances => appearances_section(app, vt), - SettingsSection::Accessibility => accessibility_section(app, vt), - }; - - let bg_card = vt.bg_card; - let border = vt.border; - let card = container(column![tab_bar, rule::horizontal(1), section_content,].spacing(16)) - .padding(20) - .width(Length::Fill) - .style(theme::card_style(bg_card, border, 8.0)); - - let content = column![header, rule::horizontal(1), card] - .spacing(16) - .padding(24) - .width(Length::Fill); - - iced::widget::scrollable(container(content).width(Length::Fill).height(Length::Fill)).into() -} - -fn section_tab<'a>( - label: &'a str, - target: SettingsSection, - current: SettingsSection, - accent: Color, -) -> Element<'a, Message> { - let is_active = current == target; - let color = if is_active { accent } else { theme::TEXT_MUTED }; - - button(text(label).size(14).color(color)) - .on_press(SettingsMessage::ChangeSection(target).into()) - .padding([8, 16]) - .style(move |_: &Theme, _| { - let bg = if is_active { - Color::from_rgb(0.15, 0.15, 0.22) - } else { - Color::TRANSPARENT - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 8.0.into(), - color: if is_active { - Color { a: 0.4, ..color } - } else { - Color::TRANSPARENT - }, - width: 1.0, - }, - ..button::Style::default() - } - }) - .into() -} - -// ───── General ───── - -fn general_section(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let settings = &app.settings; - - // Default status for new games - let default_status_row = setting_row( - "Default status for new games", - "When manually adding a game, this status will be pre-selected", - row(GameStatus::all().iter().copied().map(|s| { - option_chip( - &s.to_string(), - settings.default_status == s, - s.color(), - SettingsMessage::DefaultStatus(s), - ) - })) - .spacing(4), - vt, - ); - - // Default platform for new games - let default_platform_row = setting_row( - "Default platform", - "Pre-selected platform when adding a game manually", - row(Platform::all().iter().copied().map(|p| { - option_chip( - &p.to_string(), - settings.default_platform == p, - p.color(), - SettingsMessage::DefaultPlatform(p), - ) - })) - .spacing(4), - vt, - ); - - // Confirm before delete - let confirm_delete_row = setting_toggle( - "Confirm before deleting", - "Show a confirmation dialog before removing a game", - settings.confirm_before_delete, - SettingsMessage::ConfirmDelete.into(), - vt, - ); - - // Start screen - let start_screen_row = setting_row( - "Start screen", - "Which screen to show when Spotter launches", - row![ - option_chip( - "Library", - settings.start_screen == StartScreen::Library, - theme::ACCENT_BLUE, - SettingsMessage::StartScreen(StartScreen::Library) - ), - option_chip( - "Statistics", - settings.start_screen == StartScreen::Statistics, - theme::ACCENT_BLUE, - SettingsMessage::StartScreen(StartScreen::Statistics) - ), - option_chip( - "Import", - settings.start_screen == StartScreen::Import, - theme::ACCENT_BLUE, - SettingsMessage::StartScreen(StartScreen::Import) - ), - option_chip( - "Profile", - settings.start_screen == StartScreen::Profile, - theme::ACCENT_BLUE, - SettingsMessage::StartScreen(StartScreen::Profile) - ), - ] - .spacing(4), - vt, - ); - - // Notifications - let notifications_row = setting_toggle( - "Show notifications", - "Display success toasts after actions (errors always show)", - settings.notifications_enabled, - SettingsMessage::Notifications.into(), - vt, - ); - - // Toast duration - let toast_duration_row = setting_row( - "Toast duration", - "How long notification toasts stay on screen", - row(ToastDuration::all().iter().copied().map(|d| { - let active = settings.toast_duration == d; - option_chip( - &d.to_string(), - active, - theme::ACCENT_BLUE, - SettingsMessage::ToastDuration(d), - ) - })) - .spacing(4), - vt, - ); - - // Date format - let date_format_row = setting_row( - "Date format", - "Format used for dates throughout the app", - row(DateFormat::all().iter().copied().map(|f| { - let active = settings.date_format == f; - option_chip( - &f.to_string(), - active, - theme::ACCENT_BLUE, - SettingsMessage::DateFormat(f), - ) - })) - .spacing(4), - vt, - ); - - // Default sort order - let sort_order_row = setting_row( - "Default sort order", - "Sort order is remembered across sessions", - row(SortOrder::all().iter().copied().map(|s| { - let active = settings.default_sort_order == s; - option_chip( - &s.to_string(), - active, - theme::ACCENT_BLUE, - SettingsMessage::DefaultSortOrder(s), - ) - })) - .spacing(4) - .wrap(), - vt, - ); - - // Remember filters - let remember_filters_row = setting_toggle( - "Remember filters", - "Persist status and platform filters across sessions", - settings.remember_filters, - SettingsMessage::RememberFilters.into(), - vt, - ); - - // Show game descriptions - let show_descriptions_row = setting_toggle( - "Show descriptions in library", - "Display game descriptions in library card view", - settings.show_game_descriptions, - SettingsMessage::ShowDescriptions.into(), - vt, - ); - - // Achievements display - let achievements_display_row = setting_row( - "Achievements display", - "How achievements are shown on the game detail page", - row(AchievementsDisplay::all().iter().copied().map(|d| { - let active = settings.achievements_display == d; - option_chip( - &d.to_string(), - active, - theme::ACCENT_BLUE, - SettingsMessage::AchievementsDisplay(d), - ) - })) - .spacing(4), - vt, - ); - - // Download covers auto - let download_covers_row = setting_toggle( - "Auto-download covers on import", - "Automatically download game cover images after importing", - settings.download_covers_auto, - SettingsMessage::DownloadCoversAuto.into(), - vt, - ); - - column![ - text("General").size(18).color(theme::TEXT_LIGHT), - row![default_status_row, default_platform_row].spacing(12), - rule::horizontal(1), - row![confirm_delete_row, start_screen_row].spacing(12), - rule::horizontal(1), - row![sort_order_row, remember_filters_row].spacing(12), - rule::horizontal(1), - row![show_descriptions_row, achievements_display_row].spacing(12), - download_covers_row, - rule::horizontal(1), - row![notifications_row, toast_duration_row].spacing(12), - date_format_row, - ] - .spacing(16) - .into() -} - -// ───── Appearances ───── - -fn appearances_section(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let settings = &app.settings; - - // Theme mode - let theme_row = setting_row( - "Theme", - "Controls the overall darkness level of the interface", - row(ThemeMode::all().iter().copied().map(|t| { - let active = settings.theme_mode == t; - option_chip( - &t.to_string(), - active, - theme::ACCENT_BLUE, - SettingsMessage::ThemeMode(t), - ) - })) - .spacing(4), - vt, - ); - - // Accent color - let accent_row = setting_row( - "Accent color", - "Highlight color used throughout the interface", - row(AccentColor::all().iter().copied().map(|c| { - let active = settings.accent_color == c; - color_swatch( - &c.to_string(), - c.color(), - active, - SettingsMessage::AccentColor(c), - ) - })) - .spacing(4), - vt, - ); - - // UI scale - let scale_row = setting_row( - "UI scale", - "Adjust text and element sizes", - row(UiScale::all().iter().copied().map(|s| { - let active = settings.ui_scale == s; - let label = match s { - UiScale::Small => "Small (85%)", - UiScale::Normal => "Normal (100%)", - UiScale::Large => "Large (120%)", - }; - option_chip( - label, - active, - theme::ACCENT_BLUE, - SettingsMessage::UiScale(s), - ) - })) - .spacing(4), - vt, - ); - - // Compact list - let compact_row = setting_toggle( - "Compact library list", - "Reduce spacing between games in the library view", - settings.compact_list, - SettingsMessage::CompactList.into(), - vt, - ); - - // Show covers - let covers_row = setting_toggle( - "Show cover images in library", - "Display game cover thumbnails alongside titles", - settings.show_covers_in_list, - SettingsMessage::ShowCovers.into(), - vt, - ); - - // Sidebar width - let sidebar_row = setting_row( - "Sidebar width", - "Adjust the width of the navigation sidebar", - row![ - option_chip( - "Narrow (180)", - settings.sidebar_width == 180, - theme::ACCENT_BLUE, - SettingsMessage::SidebarWidth(180) - ), - option_chip( - "Normal (220)", - settings.sidebar_width == 220, - theme::ACCENT_BLUE, - SettingsMessage::SidebarWidth(220) - ), - option_chip( - "Wide (280)", - settings.sidebar_width == 280, - theme::ACCENT_BLUE, - SettingsMessage::SidebarWidth(280) - ), - ] - .spacing(4), - vt, - ); - - column![ - text("Appearances").size(18).color(theme::TEXT_LIGHT), - row![theme_row, accent_row].spacing(12), - rule::horizontal(1), - row![scale_row, sidebar_row].spacing(12), - rule::horizontal(1), - row![compact_row, covers_row].spacing(12), - ] - .spacing(16) - .into() -} - -// ───── Accessibility ───── - -fn accessibility_section(app: &Spotter, vt: ViewTheme) -> Element<'_, Message> { - let settings = &app.settings; - - let high_contrast_row = setting_toggle( - "High contrast mode", - "Increase contrast for text and borders to improve readability", - settings.high_contrast, - SettingsMessage::HighContrast.into(), - vt, - ); - - let status_labels_row = setting_toggle( - "Show status labels", - "Display text labels next to status indicators instead of color only", - settings.show_status_labels, - SettingsMessage::ShowStatusLabels.into(), - vt, - ); - - let large_targets_row = setting_toggle( - "Large click targets", - "Increase button and clickable area sizes for easier interaction", - settings.large_click_targets, - SettingsMessage::LargeTargets.into(), - vt, - ); - - column![ - text("Accessibility").size(18).color(theme::TEXT_LIGHT), - row![high_contrast_row, status_labels_row].spacing(12), - large_targets_row, - ] - .spacing(16) - .into() -} - -// ───── Helpers ───── - -fn setting_row<'a>( - title: &'a str, - description: &'a str, - control: impl Into>, - vt: ViewTheme, -) -> Element<'a, Message> { - let label_col = column![ - text(title).size(14).color(Color::WHITE), - text(description).size(11).color(vt.text_muted), - ] - .spacing(2) - .width(Length::Fill); - - let bg = vt.bg_row; - let border = vt.border; - container(column![label_col, control.into(),].spacing(8)) - .padding([10, 12]) - .width(Length::Fill) - .style(theme::card_style(bg, border, 6.0)) - .into() -} - -fn setting_toggle<'a>( - title: &'a str, - description: &'a str, - value: bool, - msg: Message, - vt: ViewTheme, -) -> Element<'a, Message> { - let label_col = column![ - text(title).size(14).color(Color::WHITE), - text(description).size(11).color(vt.text_muted), - ] - .spacing(2) - .width(Length::Fill); - - let toggle = toggler(value).on_toggle(move |_| msg.clone()).size(20.0); - - let bg = vt.bg_row; - let border = vt.border; - container( - row![label_col, toggle] - .spacing(12) - .align_y(iced::Alignment::Center), - ) - .padding([10, 12]) - .width(Length::Fill) - .style(theme::card_style(bg, border, 6.0)) - .into() -} - -fn option_chip( - label: &str, - active: bool, - accent: Color, - msg: impl Into, -) -> Element<'static, Message> { - let color = if active { accent } else { theme::TEXT_MUTED }; - let msg: Message = msg.into(); - - button(text(label.to_owned()).size(12).color(color)) - .on_press(msg) - .padding([5, 10]) - .style(theme::chip_style(active, color, accent, 6.0)) - .into() -} - -fn color_swatch( - label: &str, - color: Color, - active: bool, - msg: impl Into, -) -> Element<'static, Message> { - let msg: Message = msg.into(); - let dot = container(text("").size(1)) - .width(Length::Fixed(16.0)) - .height(Length::Fixed(16.0)) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(color)), - border: Border { - radius: 8.0.into(), - ..Border::default() - }, - ..container::Style::default() - }); - - let lbl = text(label.to_owned()) - .size(12) - .color(if active { color } else { theme::TEXT_MUTED }); - - button(row![dot, lbl].spacing(6).align_y(iced::Alignment::Center)) - .on_press(msg) - .padding([5, 10]) - .style(move |_: &Theme, _| { - let bg = if active { - Color::from_rgb(0.14, 0.14, 0.20) - } else { - theme::BG_BUTTON - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: color, - border: Border { - radius: 6.0.into(), - color: if active { - Color { a: 0.4, ..color } - } else { - Color::TRANSPARENT - }, - width: 1.0, - }, - ..button::Style::default() - } - }) - .into() -} diff --git a/src/views/sidebar.rs b/src/views/sidebar.rs index 889333f..285347d 100644 --- a/src/views/sidebar.rs +++ b/src/views/sidebar.rs @@ -1,168 +1,109 @@ -use iced::widget::{button, column, container, rule, space, text}; -use iced::{Background, Border, Color, Element, Length, Theme}; +use colony_ui::theme::Palette; +use colony_ui::widgets::{identity_button, nav_hint, nav_label, selection_style}; +use colony_ui::Typography; +use iced::widget::{button, column, container, rule, space}; +use iced::{Background, Element, Length, Theme}; use crate::app::{Message, Screen, Spotter}; -use crate::theme::{self, ViewTheme}; +use crate::i18n::{t, tf, tn}; +use crate::messages::PreferencesMessage; +use crate::models::GameStatus; +use crate::theme::{self, ui_text}; pub fn view(app: &Spotter) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); - let accent = app.settings.accent_color.color(); - let is_settings = app.screen == Screen::Settings; - let large = app.settings.large_click_targets; - - let title_label = format!("{} SPOTTER", theme::icons::GAMEPAD); - let title_text = text(title_label) - .size(22) - .font(theme::FONT_BOLD) - .color(if is_settings { Color::WHITE } else { accent }); - - let title = button(title_text) - .on_press(Message::NavigateTo(Screen::Settings)) - .padding(0) - .style(move |_: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - button::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - text_color: accent, - border: if hover { - Border { - width: 0.0, - color: accent, - radius: 0.0.into(), - } - } else { - Border::default() - }, - shadow: if hover { - iced::Shadow { - color: accent, - offset: iced::Vector::new(0.0, 2.0), - blur_radius: 0.0, - } - } else { - iced::Shadow::default() - }, - ..button::Style::default() - } - }); - - let subtitle = text("Game Tracker").size(12).color(vt.text_muted); - - let total_games = text(format!("{} games", app.games.len())) - .size(11) - .color(vt.text_dim); - - let nav_pad = if large { 12 } else { 8 }; - let nav_library = nav_button( - &format!("{} Library", theme::icons::LIBRARY), - Screen::Library, - &app.screen, - accent, - nav_pad, - vt, - ); - let nav_add_game = nav_button( - &format!("{} Add Game", theme::icons::ADD), - Screen::AddGame, - &app.screen, - accent, - nav_pad, - vt, - ); - let nav_stats = nav_button( - &format!("{} Statistics", theme::icons::STATS), - Screen::Statistics, - &app.screen, - accent, - nav_pad, - vt, - ); - let nav_import = nav_button( - &format!("{} Import", theme::icons::IMPORT), - Screen::Import, - &app.screen, - accent, - nav_pad, - vt, - ); - let nav_profile = nav_button( - &format!("{} Profile", theme::icons::PROFILE), - Screen::Profile, - &app.screen, - accent, - nav_pad, - vt, + let typo = Typography::current(); + // The program's name is the way into Preferences, and back out. + let identity = identity_button( + &typo, + "Spotter", + 24, + app.screen == Screen::Preferences, + PreferencesMessage::Toggle.into(), ); + let tagline = ui_text(t("app_tagline"), 12).color(Palette::TEXT_MUTED()); + + let nav_pad = app.preferences.button_padding(); + let nav = |icon: &str, key: &str, target: Screen| { + nav_button(&format!("{icon} {}", t(key)), target, &app.screen, nav_pad) + }; + let modifier = if cfg!(target_os = "macos") { + "Cmd" + } else { + "Ctrl" + }; let playing_count = app .games .iter() - .filter(|g| g.status == crate::models::GameStatus::Playing) + .filter(|g| g.status == GameStatus::Playing) .count(); let completed_count = app .games .iter() - .filter(|g| g.status == crate::models::GameStatus::Completed) + .filter(|g| g.status == GameStatus::Completed) .count(); let backlog_count = app .games .iter() - .filter(|g| g.status == crate::models::GameStatus::Unplayed) + .filter(|g| g.status == GameStatus::Unplayed) .count(); - let fav_count = app.settings.favorites.len(); + let fav_count = app.preferences.favorites.len(); + let stat = |name: &str, count: usize| { + ui_text( + format!( + " {}", + tf("quick_stat", &[("name", &name), ("count", &count)]) + ), + 12, + ) + }; let mut quick_stats = column![ - text("Quick Stats").size(11).color(vt.text_muted), - text(format!(" Playing: {}", playing_count)) - .size(12) - .color(theme::SUCCESS), - text(format!(" Completed: {}", completed_count)) - .size(12) - .color(theme::COMPLETED_BLUE), - text(format!(" Unplayed: {}", backlog_count)) - .size(12) - .color(Color::from_rgb(0.9, 0.7, 0.2)), + ui_text(t("quick_stats"), 11).color(Palette::TEXT_MUTED()), + ui_text(tn("games_count", app.games.len(), &[]), 11).color(Palette::TEXT_DIM()), + stat(GameStatus::Playing.label(), playing_count).color(GameStatus::Playing.color()), + stat(GameStatus::Completed.label(), completed_count).color(GameStatus::Completed.color()), + stat(GameStatus::Unplayed.label(), backlog_count).color(GameStatus::Unplayed.color()), ] .spacing(4); if fav_count > 0 { quick_stats = quick_stats.push( - text(format!(" {} Favorites: {}", theme::icons::STAR, fav_count)) - .size(12) - .color(theme::ACCENT_GOLD), + stat( + &format!("{} {}", theme::icons::STAR, t("favorites")), + fav_count, + ) + .color(Palette::WARNING()), ); } let user_badge = container( - text(format!( - "{} {}", - theme::icons::PROFILE, - app.profile.username - )) - .size(12) - .color(Color::from_rgb(0.7, 0.8, 1.0)), + ui_text( + format!("{} {}", theme::icons::PROFILE, app.profile.username), + 12, + ) + .color(Palette::TEXT_SECONDARY()), ) .padding([4, 8]) - .style(move |_theme: &Theme| container::Style { - background: Some(Background::Color(vt.bg_card)), - border: Border { - radius: 8.0.into(), - color: vt.border, - width: 1.0, - }, - ..container::Style::default() - }); + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 8.0, + )); let sidebar_content = column![ - title, - subtitle, - total_games, + column![identity, tagline].spacing(4), rule::horizontal(1), - nav_library, - nav_add_game, - nav_stats, - nav_import, - nav_profile, + nav_label(&typo, t("nav_label")), + column![ + nav(theme::icons::LIBRARY, "nav_library", Screen::Library), + nav(theme::icons::ADD, "nav_add_game", Screen::AddGame), + nav(theme::icons::STATS, "nav_statistics", Screen::Statistics), + nav(theme::icons::IMPORT, "nav_import", Screen::Import), + nav(theme::icons::PROFILE, "nav_profile", Screen::Profile), + ] + .spacing(4), + nav_hint(&typo, &tf("nav_hint", &[("modifier", &modifier)])), rule::horizontal(1), quick_stats, space::vertical(), @@ -171,18 +112,12 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { .spacing(12) .padding(20); - let sidebar_width = app.settings.sidebar_width.clamp(160, 400) as f32; - let bg_sidebar = vt.bg_sidebar; + let sidebar_width = app.preferences.sidebar_width.clamp(160, 400) as f32; let sidebar = container(sidebar_content) .width(Length::Fixed(sidebar_width)) .height(Length::Fill) - .style(move |_theme: &Theme| container::Style { - background: Some(Background::Color(bg_sidebar)), - border: Border { - color: Color::from_rgb(0.15, 0.15, 0.2), - width: 0.0, - radius: 0.0.into(), - }, + .style(|_theme: &Theme| container::Style { + background: Some(Background::Color(Palette::BG_SIDEBAR())), ..container::Style::default() }); @@ -193,54 +128,16 @@ fn nav_button( label: &str, target: Screen, current: &Screen, - accent: Color, pad: u16, - vt: ViewTheme, ) -> Element<'static, Message> { - let label = label.to_string(); let is_active = *current == target || matches!((current, &target), (Screen::GameDetail(_), Screen::Library)); - let label_color = if is_active { - accent - } else { - theme::TEXT_SECONDARY - }; - - let label_text = text(label).size(14).color(label_color); - - let active_bg = vt.bg_card; - button(label_text) + // Selection is shown by background, as in every Colony sidebar. + button(ui_text(label.to_string(), 14)) .on_press(Message::NavigateTo(target)) .padding([pad, 14]) .width(Length::Fill) - .style(move |_theme: &Theme, status| { - let hover = matches!(status, button::Status::Hovered | button::Status::Pressed); - let bg = if is_active { - if hover { - crate::theme::lighten(active_bg, 0.04) - } else { - active_bg - } - } else if hover { - Color::from_rgba(1.0, 1.0, 1.0, 0.04) - } else { - Color::TRANSPARENT - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: label_color, - border: Border { - radius: 8.0.into(), - color: if is_active { - accent - } else { - Color::TRANSPARENT - }, - width: if is_active { 1.0 } else { 0.0 }, - }, - ..button::Style::default() - } - }) + .style(selection_style(is_active)) .into() } diff --git a/src/views/stats.rs b/src/views/stats.rs index 4daeac3..f3481c0 100644 --- a/src/views/stats.rs +++ b/src/views/stats.rs @@ -1,21 +1,21 @@ -use iced::widget::{column, container, progress_bar, row, rule, space, text}; +use colony_ui::theme::Palette; +use iced::widget::{column, container, progress_bar, row, rule, space}; use iced::{Background, Border, Color, Element, Length, Theme}; use crate::app::{Message, Spotter}; +use crate::i18n::{decimal, percent, t, tf, tn}; use crate::models::{GameStatus, Platform}; -use crate::theme::{self, ViewTheme}; +use crate::theme::{self, ui_text}; pub fn view(app: &Spotter) -> Element<'_, Message> { - let vt = ViewTheme::from_settings(&app.settings); - let header = column![ - text(format!("{} Statistics", theme::icons::STATS)) - .size(24) - .font(theme::FONT_BOLD) - .color(Color::WHITE), - text("Overview of your gaming journey") - .size(12) - .color(vt.text_muted), + ui_text( + format!("{} {}", theme::icons::STATS, t("nav_statistics")), + 24 + ) + .font(colony_ui::ui_font_bold()) + .color(Palette::TEXT_PRIMARY()), + ui_text(t("stats_subtitle"), 12).color(Palette::TEXT_MUTED()), ] .spacing(4); @@ -39,46 +39,42 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { // Overview cards let overview = row![ stat_card( - "Total Games", + t("stats_games"), format!("{}", app.games.len()), - theme::ACCENT_BLUE, - vt + Palette::ACCENT() ), stat_card( - "Total Playtime", + t("stats_total_playtime"), format_playtime(total_playtime), - theme::SUCCESS, - vt + Palette::SUCCESS() ), stat_card( - "Achievements", + t("detail_achievements"), format!("{}/{}", total_achievements, total_possible), - theme::ACCENT_GOLD, - vt + Palette::WARNING() ), stat_card( - "Avg Rating", - format!("{:.1}/10", avg_rating), - Color::from_rgb(0.9, 0.5, 0.9), - vt + t("stats_average_rating"), + format!("{}/10", decimal(avg_rating, 1)), + theme::shared_accent("violet") ), ] .spacing(12); // Status distribution - let status_dist = status_distribution(app, vt); + let status_dist = status_distribution(app); // Platform distribution - let platform_dist = platform_distribution(app, vt); + let platform_dist = platform_distribution(app); // Playtime graph - let playtime_graph = playtime_chart(app, vt); + let playtime_graph = playtime_chart(app); // Most played games - let most_played = most_played_games(app, vt); + let most_played = most_played_games(app); // Period comparison - let comparison = period_comparison(app, vt); + let comparison = period_comparison(app); let content = column![ header, @@ -96,65 +92,47 @@ pub fn view(app: &Spotter) -> Element<'_, Message> { iced::widget::scrollable(container(content).width(Length::Fill).height(Length::Fill)).into() } -fn stat_card<'a>( - label: &'a str, - value: String, - color: Color, - vt: ViewTheme, -) -> Element<'a, Message> { +fn stat_card<'a>(label: &'a str, value: String, color: Color) -> Element<'a, Message> { let content = column![ - text(label).size(12).color(vt.text_muted), - text(value).size(24).color(color), + ui_text(label, 12).color(Palette::TEXT_MUTED()), + ui_text(value, 24).color(color), ] .spacing(6); - let bg = vt.bg_card; - let border = vt.border; container(content) .padding(16) .width(Length::Fill) - .style(move |_theme: &Theme| container::Style { - background: Some(Background::Color(bg)), - border: Border { - radius: 8.0.into(), - color: border, - width: 1.0, - }, - ..container::Style::default() - }) + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 8.0, + )) .into() } fn section_card<'a>( title: &'a str, content: iced::widget::Column<'a, Message>, - vt: ViewTheme, ) -> Element<'a, Message> { let section = column![ - text(title).size(16).color(vt.text_light), + ui_text(title, 16).color(Palette::TEXT_PRIMARY()), rule::horizontal(1), content, ] .spacing(10); - let bg = vt.bg_card; - let border = vt.border; container(section) .padding(16) .width(Length::Fill) - .style(move |_theme: &Theme| container::Style { - background: Some(Background::Color(bg)), - border: Border { - radius: 8.0.into(), - color: border, - width: 1.0, - }, - ..container::Style::default() - }) + .style(theme::card_style( + Palette::BG_CARD(), + Palette::BORDER_SUBTLE(), + 8.0, + )) .into() } -fn playtime_chart<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { +fn playtime_chart<'a>(app: &'a Spotter) -> Element<'a, Message> { let max_height: f32 = 120.0; let is_sample_data = app.playtime_data.is_empty(); @@ -167,13 +145,8 @@ fn playtime_chart<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { if data.is_empty() { return section_card( - "Playtime (Last 30 Days)", - column![ - text("No playtime data yet. Import games to start tracking!") - .size(13) - .color(vt.text_muted), - ], - vt, + t("stats_playtime_30_days"), + column![ui_text(t("stats_no_playtime"), 13).color(Palette::TEXT_MUTED()),], ); } @@ -188,7 +161,7 @@ fn playtime_chart<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { .width(Length::Fill) .height(Length::Fixed(bar_height)) .style(move |_: &Theme| container::Style { - background: Some(Background::Color(theme::COMPLETED_BLUE)), + background: Some(Background::Color(Palette::ACCENT_PROGRESS())), border: Border { radius: 2.0.into(), ..Border::default() @@ -205,7 +178,7 @@ fn playtime_chart<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { let bar_with_label = column![ space::vertical().height(Length::Fixed(max_height - bar_height)), bar, - text(label).size(8).color(theme::TEXT_DIM), + ui_text(label, 8).color(Palette::TEXT_DIM()), ] .align_x(iced::Alignment::Center) .width(Length::Fill); @@ -214,13 +187,15 @@ fn playtime_chart<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { } let total_tracked: u32 = data.iter().map(|(_, m)| *m).sum(); - let subtitle = text(format!( - "Total: {} tracked over {} days", - format_playtime(total_tracked), - data.len() - )) - .size(11) - .color(vt.text_muted); + let subtitle = ui_text( + tn( + "stats_tracked", + data.len(), + &[("time", &format_playtime(total_tracked))], + ), + 11, + ) + .color(Palette::TEXT_MUTED()); let mut chart_content = column![ container(bars_row) @@ -231,14 +206,11 @@ fn playtime_chart<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { .spacing(8); if is_sample_data { - chart_content = chart_content.push( - text("Estimated from currently playing games (no recorded playtime data yet)") - .size(10) - .color(theme::TEXT_DIM), - ); + chart_content = + chart_content.push(ui_text(t("stats_estimated"), 10).color(Palette::TEXT_DIM())); } - section_card("Playtime (Last 30 Days)", chart_content, vt) + section_card(t("stats_playtime_30_days"), chart_content) } fn generate_sample_playtime(app: &Spotter) -> Vec<(String, u32)> { @@ -264,7 +236,7 @@ fn generate_sample_playtime(app: &Spotter) -> Vec<(String, u32)> { data } -fn status_distribution<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { +fn status_distribution<'a>(app: &'a Spotter) -> Element<'a, Message> { let total = app.games.len() as f32; let statuses = GameStatus::all(); @@ -277,25 +249,23 @@ fn status_distribution<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Messa 0.0 }; let bar_row = row![ - text(format!("{}", status)) - .size(12) + ui_text(status.label(), 12) .color(status.color()) - .width(90), + .width(label_width()), container(progress_bar(0.0..=100.0, pct).girth(10)).width(Length::Fill), - text(format!("{} ({:.0}%)", count, pct)) - .size(12) - .color(vt.text_secondary) - .width(80), + ui_text(format!("{} ({})", count, percent(pct, 0)), 12) + .color(Palette::TEXT_SECONDARY()) + .width(count_width()), ] .spacing(8) .align_y(iced::Alignment::Center); bars = bars.push(bar_row); } - section_card("Status Distribution", bars, vt) + section_card(t("stats_by_status"), bars) } -fn platform_distribution<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { +fn platform_distribution<'a>(app: &'a Spotter) -> Element<'a, Message> { let total = app.games.len() as f32; let platforms = Platform::all(); @@ -311,25 +281,23 @@ fn platform_distribution<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mes 0.0 }; let bar_row = row![ - text(format!("{}", platform)) - .size(12) + ui_text(platform.to_string(), 12) .color(platform.color()) - .width(90), + .width(label_width()), container(progress_bar(0.0..=100.0, pct).girth(10)).width(Length::Fill), - text(format!("{} ({:.0}%)", count, pct)) - .size(12) - .color(vt.text_secondary) - .width(80), + ui_text(format!("{} ({})", count, percent(pct, 0)), 12) + .color(Palette::TEXT_SECONDARY()) + .width(count_width()), ] .spacing(8) .align_y(iced::Alignment::Center); bars = bars.push(bar_row); } - section_card("Platform Distribution", bars, vt) + section_card(t("stats_by_platform"), bars) } -fn most_played_games<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { +fn most_played_games<'a>(app: &'a Spotter) -> Element<'a, Message> { let mut sorted: Vec<&crate::models::Game> = app.games.iter().collect(); sorted.sort_by_key(|b| std::cmp::Reverse(b.playtime_minutes)); @@ -339,22 +307,23 @@ fn most_played_games<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message for game in sorted.iter().take(5) { let pct = (game.playtime_minutes as f32 / max_time) * 100.0; let game_row = row![ - text(&game.title).size(13).color(Color::WHITE).width(200), + ui_text(&game.title, 13) + .color(Palette::TEXT_PRIMARY()) + .width(200), container(progress_bar(0.0..=100.0, pct).girth(10)).width(Length::Fill), - text(game.playtime_display()) - .size(12) - .color(theme::ACCENT_BLUE) - .width(80), + ui_text(game.playtime_display(), 12) + .color(Palette::ACCENT()) + .width(count_width()), ] .spacing(8) .align_y(iced::Alignment::Center); rows = rows.push(game_row); } - section_card("Most Played", rows, vt) + section_card(t("stats_most_played"), rows) } -fn period_comparison<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { +fn period_comparison<'a>(app: &'a Spotter) -> Element<'a, Message> { let now = chrono::Local::now(); let this_month = now.format("%Y-%m").to_string(); let last_month = (now - chrono::Duration::days(30)) @@ -401,63 +370,56 @@ fn period_comparison<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message } else if playtime_delta < 0 { format!("-{}", format_playtime((-playtime_delta) as u32)) } else { - "same".to_string() + t("stats_same").to_owned() }; let playtime_color = if playtime_delta > 0 { - theme::SUCCESS + Palette::SUCCESS() } else if playtime_delta < 0 { - Color::from_rgb(1.0, 0.4, 0.4) + Palette::ERROR() } else { - vt.text_muted + Palette::TEXT_MUTED() }; let completed_delta = completed_this as i64 - completed_last as i64; - let completed_arrow = if completed_delta > 0 { - format!("+{}", completed_delta) - } else if completed_delta < 0 { - format!("{}", completed_delta) + let completed_arrow = if completed_delta == 0 { + t("stats_completed_same").to_owned() } else { - "same".to_string() + tf( + "stats_completed_change", + &[("change", &format!("{completed_delta:+}"))], + ) }; let completed_color = if completed_delta > 0 { - theme::SUCCESS + Palette::SUCCESS() } else if completed_delta < 0 { - Color::from_rgb(1.0, 0.4, 0.4) + Palette::ERROR() } else { - vt.text_muted + Palette::TEXT_MUTED() }; let content = column![row![ column![ - text("This Month").size(11).color(vt.text_muted), - text(format_playtime(this_month_playtime)) - .size(20) - .color(theme::ACCENT_BLUE), - text(format!("{} completed", completed_this)) - .size(12) - .color(theme::COMPLETED_BLUE), + ui_text(t("stats_this_month"), 11).color(Palette::TEXT_MUTED()), + ui_text(format_playtime(this_month_playtime), 20).color(Palette::ACCENT()), + ui_text(tn("stats_completed", completed_this, &[]), 12) + .color(GameStatus::Completed.color()), ] .spacing(4) .width(Length::Fill), column![ - text("vs Last Month").size(11).color(vt.text_muted), - text(format_playtime(last_month_playtime)) - .size(20) - .color(vt.text_secondary), - text(format!("{} completed", completed_last)) - .size(12) - .color(vt.text_secondary), + ui_text(t("stats_last_month"), 11).color(Palette::TEXT_MUTED()), + ui_text(format_playtime(last_month_playtime), 20).color(Palette::TEXT_SECONDARY()), + ui_text(tn("stats_completed", completed_last, &[]), 12) + .color(Palette::TEXT_SECONDARY()), ] .spacing(4) .width(Length::Fill), column![ - text("Change").size(11).color(vt.text_muted), - text(playtime_arrow).size(20).color(playtime_color), - text(format!("{} completed", completed_arrow)) - .size(12) - .color(completed_color), + ui_text(t("stats_change"), 11).color(Palette::TEXT_MUTED()), + ui_text(playtime_arrow, 20).color(playtime_color), + ui_text(completed_arrow, 12).color(completed_color), ] .spacing(4) .width(Length::Fill), @@ -465,9 +427,20 @@ fn period_comparison<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message .spacing(16),] .spacing(8); - section_card("Month Comparison", content, vt) + section_card(t("stats_month_comparison"), content) } fn format_playtime(minutes: u32) -> String { crate::models::format_playtime(minutes) } + +/// The width of a bar's label, with room for the longer French names at +/// every text size. +fn label_width() -> f32 { + colony_ui::sz(130) +} + +/// The width of a bar's count or time. +fn count_width() -> f32 { + colony_ui::sz(90) +} diff --git a/src/xbox.rs b/src/xbox.rs index 1a23cee..c7e4b2a 100644 --- a/src/xbox.rs +++ b/src/xbox.rs @@ -8,7 +8,7 @@ use crate::models::*; struct XblTitleHistory { #[serde(default)] titles: Vec, - /// Continuation token for pagination — present when more results are available. + /// Continuation token for pagination: present when more results are available. #[serde(alias = "continuationToken")] continuation_token: Option, } @@ -232,7 +232,7 @@ fn fetch_achievements_x360( 2, )?; - // Parse as generic JSON — the x360 format uses different field types/names + // Parse as generic JSON: the x360 format uses different field types/names let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| format!("x360 parse error: {}", e))?; @@ -337,7 +337,7 @@ fn fetch_marketplace_details( } // The marketplace endpoint uses product IDs, not title IDs. - // Try sending title IDs — if the API doesn't recognize them, we get empty results (harmless). + // Try sending title IDs: if the API doesn't recognize them, we get empty results (harmless). let products_param = title_ids.join(","); let url = "https://xbl.io/api/v2/marketplace/details"; @@ -421,7 +421,7 @@ pub fn full_import(api_key: &str) -> Result<(Vec, Option), String> return Err("Xbox API key is required. Get one at xbl.io".into()); } - let logger = api_client::ImportLogger::new(&api_client::log_dir()); + let logger = api_client::ImportLogger::for_imports(); logger.log("xbox", "Starting Xbox import"); // Step 1: Get XUID (needed for achievement endpoints) @@ -524,7 +524,7 @@ pub fn full_import(api_key: &str) -> Result<(Vec, Option), String> let release_date = meta.map(|m| m.release_date.clone()).unwrap_or_default(); // Fetch detailed achievements if we have a XUID. - // Always try — the title history often reports 0 achievements even for + // Always try: the title history often reports 0 achievements even for // games that have them (especially Xbox 360 titles). let (ach_unlocked, ach_total) = if !xuid.is_empty() { // Try modern endpoint first @@ -534,7 +534,7 @@ pub fn full_import(api_key: &str) -> Result<(Vec, Option), String> Ok(d) } modern_result => { - // Modern endpoint returned empty or error — try x360 endpoint. + // Modern endpoint returned empty or error: try x360 endpoint. // Many older titles (Fable II, Fallout 3, etc.) only return data // from the x360 endpoint, even when not tagged as "Xbox360" in devices. eprintln!( @@ -574,7 +574,7 @@ pub fn full_import(api_key: &str) -> Result<(Vec, Option), String> } eprintln!( - "[xbox] [{}/{}] {} — {} achievements ({} unlocked)", + "[xbox] [{}/{}] {}: {} achievements ({} unlocked)", i + 1, total, title.name, @@ -586,7 +586,7 @@ pub fn full_import(api_key: &str) -> Result<(Vec, Option), String> } Ok(_) => { eprintln!( - "[xbox] [{}/{}] {} — no achievements", + "[xbox] [{}/{}] {}: no achievements", i + 1, total, title.name @@ -595,7 +595,7 @@ pub fn full_import(api_key: &str) -> Result<(Vec, Option), String> } Err(e) => { eprintln!( - "[xbox] [{}/{}] {} — achievement fetch failed: {}", + "[xbox] [{}/{}] {}: achievement fetch failed: {}", i + 1, total, title.name, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 7b85f2c..0e30bbe 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1415,7 +1415,7 @@ mod import_logger { .join("spotter_log_test_nonexistent") .join("deeply") .join("nested"); - // Don't create the directory — logger should fail gracefully + // Don't create the directory: logger should fail gracefully let logger = ImportLogger::new(&dir); // This should not panic, just print to stderr logger.log("test", "Should not crash"); diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index 5e890f1..f098380 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU32, Ordering}; static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); /// Create an isolated temp dir with a fresh DB connection. -/// Each call returns a unique path and connection — no global env-var side effects, +/// Each call returns a unique path and connection: no global env-var side effects, /// so tests can safely run in parallel. fn setup_test_db() -> (std::path::PathBuf, rusqlite::Connection) { let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); @@ -227,72 +227,31 @@ fn game_status_display_format() { assert_eq!(format!("{}", GameStatus::Dropped), "Dropped"); } -// ───── Settings tests ───── +// ───── Preferences tests ───── #[test] -fn settings_default_values() { - let s = spotter::models::Settings::default(); +fn preferences_default_values() { + let s = spotter::models::Preferences::default(); assert_eq!(s.default_status, spotter::models::GameStatus::Unplayed); assert_eq!(s.default_platform, spotter::models::Platform::Steam); assert!(s.confirm_before_delete); assert_eq!(s.start_screen, spotter::models::StartScreen::Library); - assert_eq!(s.theme_mode, spotter::models::ThemeMode::Dark); - assert_eq!(s.accent_color, spotter::models::AccentColor::Blue); + assert_eq!( + s.standard, + colony_ui::preferences::StandardPreferences::default() + ); assert!(!s.compact_list); assert!(s.show_covers_in_list); - assert_eq!(s.ui_scale, spotter::models::UiScale::Normal); - assert!(!s.high_contrast); assert!(s.show_status_labels); assert!(!s.large_click_targets); } #[test] -fn settings_serde_roundtrip() { - let original = spotter::models::Settings::default(); +fn preferences_serde_roundtrip() { + let original = spotter::models::Preferences::default(); let json = serde_json::to_string(&original).unwrap(); - let parsed: spotter::models::Settings = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.theme_mode, original.theme_mode); - assert_eq!(parsed.accent_color, original.accent_color); - assert_eq!(parsed.ui_scale, original.ui_scale); - assert_eq!(parsed.compact_list, original.compact_list); -} - -#[test] -fn ui_scale_factors() { - use spotter::models::UiScale; - assert!((UiScale::Small.factor() - 0.85).abs() < f32::EPSILON); - assert!((UiScale::Normal.factor() - 1.0).abs() < f32::EPSILON); - assert!((UiScale::Large.factor() - 1.2).abs() < f32::EPSILON); -} - -// ───── Theme tests ───── - -#[test] -fn theme_mode_bg_card_differs() { - use spotter::models::ThemeMode; - let dark = ThemeMode::Dark.bg_card(); - let darker = ThemeMode::Darker.bg_card(); - let midnight = ThemeMode::Midnight.bg_card(); - // Each mode should produce different brightness - assert!(dark.r > darker.r); - assert!(darker.r > midnight.r); -} - -#[test] -fn view_theme_high_contrast_brighter() { - use spotter::models::Settings; - use spotter::theme::ViewTheme; - - let mut s = Settings::default(); - let normal = ViewTheme::from_settings(&s); - - s.high_contrast = true; - let hc = ViewTheme::from_settings(&s); - - // High contrast text should be brighter (higher r value) - assert!(hc.text_muted.r > normal.text_muted.r); - assert!(hc.text_dim.r > normal.text_dim.r); - assert!(hc.border.r > normal.border.r); + let parsed: spotter::models::Preferences = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, original); } // ───── DB tests ───── @@ -380,29 +339,6 @@ fn db_profile_crud() { let _ = std::fs::remove_dir_all(&tmp); } -#[test] -fn db_settings_roundtrip() { - let (tmp, conn) = setup_test_db(); - - // Load defaults - let settings = spotter::db::load_settings(&conn).expect("Should load settings"); - assert_eq!(settings.theme_mode, spotter::models::ThemeMode::Dark); - - // Modify and save - let mut s = settings; - s.theme_mode = spotter::models::ThemeMode::Midnight; - s.high_contrast = true; - s.compact_list = true; - spotter::db::save_settings(&conn, &s).expect("Should save settings"); - - let loaded = spotter::db::load_settings(&conn).expect("Should reload settings"); - assert_eq!(loaded.theme_mode, spotter::models::ThemeMode::Midnight); - assert!(loaded.high_contrast); - assert!(loaded.compact_list); - - let _ = std::fs::remove_dir_all(&tmp); -} - #[test] fn db_achievements_roundtrip() { let (tmp, conn) = setup_test_db(); @@ -471,91 +407,62 @@ fn error_converts_to_string() { assert_eq!(s, "Import error: no games found"); } -// ───── Settings versioning tests ───── +// ───── Preferences versioning tests ───── #[test] -fn db_settings_unknown_value_falls_back_per_field() { - let (tmp, conn) = setup_test_db(); - - // A theme and an accent this build does not know (as a newer version could - // write), next to valid values that must survive. - let mut stored = serde_json::to_value(spotter::models::Settings { - compact_list: true, - high_contrast: true, - sidebar_width: 300, - ui_scale: spotter::models::UiScale::Large, - ..Default::default() - }) - .unwrap(); - stored["theme_mode"] = "Oled".into(); - stored["accent_color"] = serde_json::json!(42); - conn.execute( - "INSERT INTO settings (id, data) VALUES (1, ?1)", - [stored.to_string()], +fn preferences_unknown_value_falls_back_per_field() { + // A status this build does not know (as a newer version could write), + // next to valid values that must survive. + let loaded = spotter::preferences::parse( + r#"{"default_status":"Abandoned","compact_list":true,"sidebar_width":300, + "high_contrast":true,"text_size_a11y":"large"}"#, ) - .unwrap(); - - let loaded = spotter::db::load_settings(&conn).expect("one bad field must not fail the load"); - assert_eq!(loaded.theme_mode, spotter::models::ThemeMode::Dark); - assert_eq!(loaded.accent_color, spotter::models::AccentColor::Blue); + .expect("one bad field must not fail the load"); + assert_eq!(loaded.default_status, spotter::models::GameStatus::Unplayed); assert!(loaded.compact_list); - assert!(loaded.high_contrast); assert_eq!(loaded.sidebar_width, 300); - assert_eq!(loaded.ui_scale, spotter::models::UiScale::Large); - - let _ = std::fs::remove_dir_all(&tmp); + assert!(loaded.standard.high_contrast); + assert_eq!(loaded.standard.text_size_a11y, colony_ui::TextSize::Large); } #[test] -fn db_settings_missing_required_field_keeps_the_rest() { - let (tmp, conn) = setup_test_db(); - // Old row without theme_mode, which has no serde default of its own. - conn.execute( - "INSERT INTO settings (id, data) VALUES (1, ?1)", - [r#"{"compact_list":true,"start_screen":"Statistics"}"#], - ) - .unwrap(); - - let loaded = spotter::db::load_settings(&conn).unwrap(); - assert_eq!(loaded.theme_mode, spotter::models::ThemeMode::Dark); +fn preferences_missing_fields_keep_the_rest() { + let loaded = + spotter::preferences::parse(r#"{"compact_list":true,"start_screen":"Statistics"}"#) + .unwrap(); assert!(loaded.compact_list); assert_eq!( loaded.start_screen, spotter::models::StartScreen::Statistics ); - - let _ = std::fs::remove_dir_all(&tmp); + assert_eq!(loaded.sidebar_width, 220); } #[test] -fn db_settings_unreadable_json_is_an_error() { - let (tmp, conn) = setup_test_db(); - conn.execute("INSERT INTO settings (id, data) VALUES (1, 'not json')", []) - .unwrap(); - assert!(spotter::db::load_settings(&conn).is_err()); - let _ = std::fs::remove_dir_all(&tmp); +fn preferences_unreadable_json_is_an_error() { + assert!(spotter::preferences::parse("not json").is_err()); } #[test] -fn settings_default_has_version() { - let s = spotter::models::Settings::default(); - assert_eq!(s.version, spotter::models::SETTINGS_VERSION); +fn preferences_default_has_version() { + let s = spotter::models::Preferences::default(); + assert_eq!(s.version, spotter::models::PREFERENCES_VERSION); } #[test] -fn settings_version_survives_serialization() { - let original = spotter::models::Settings::default(); +fn preferences_version_survives_serialization() { + let original = spotter::models::Preferences::default(); let json = serde_json::to_string(&original).unwrap(); - let parsed: spotter::models::Settings = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.version, spotter::models::SETTINGS_VERSION); + let parsed: spotter::models::Preferences = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.version, spotter::models::PREFERENCES_VERSION); } #[test] -fn settings_without_version_gets_default() { +fn preferences_without_version_gets_default() { // Simulate old settings JSON without version field let json = r#"{"default_status":"Unplayed","default_platform":"Steam","confirm_before_delete":true,"start_screen":"Library","theme_mode":"Dark","accent_color":"Blue","compact_list":false,"show_covers_in_list":true,"ui_scale":"Normal","sidebar_width":220,"high_contrast":false,"show_status_labels":true,"large_click_targets":false}"#; - let parsed: spotter::models::Settings = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.version, spotter::models::SETTINGS_VERSION); + let parsed: spotter::models::Preferences = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.version, spotter::models::PREFERENCES_VERSION); } // ───── CSV export test ───── @@ -659,7 +566,7 @@ fn date_format_empty_input() { // Empty strings and dash-placeholders are passed through as-is assert_eq!(DateFormat::Ymd.format_date(""), ""); assert_eq!(DateFormat::Dmy.format_date(""), ""); - assert_eq!(DateFormat::Mdy.format_date("—"), "—"); + assert_eq!(DateFormat::Mdy.format_date("\u{2014}"), "\u{2014}"); } // ───── JSON export test ───── @@ -753,16 +660,6 @@ fn game_status_all_includes_five() { assert_eq!(all.len(), 5); } -#[test] -fn accent_color_all_variants() { - let all = spotter::models::AccentColor::all(); - assert_eq!(all.len(), 6); - for ac in all { - let c = ac.color(); - assert!(c.r > 0.0 || c.g > 0.0 || c.b > 0.0); - } -} - #[test] fn toast_duration_millis() { assert_eq!(spotter::models::ToastDuration::Short.millis(), 2000); @@ -857,57 +754,67 @@ fn game_status_icons_non_empty() { } #[test] -fn platform_colors_non_black() { +fn platform_colors_are_distinct_shared_accents() { use spotter::models::Platform; for p in Platform::all() { - let c = p.color(); assert!( - c.r > 0.0 || c.g > 0.0 || c.b > 0.0, - "Platform {:?} color should not be black", + colony_ui::accent_key_to_color(p.accent_key()).is_some(), + "Platform {:?} names an accent colony-ui does not ship", p ); } + let colors: Vec<_> = Platform::all().iter().map(|p| p.color()).collect(); + for (i, a) in colors.iter().enumerate() { + for b in &colors[i + 1..] { + assert_ne!(a, b, "two platforms share a colour"); + } + } } #[test] -fn game_status_colors_non_black() { +fn game_status_colors_are_distinct_shared_accents() { use spotter::models::GameStatus; for s in GameStatus::all() { - let c = s.color(); assert!( - c.r > 0.0 || c.g > 0.0 || c.b > 0.0, - "GameStatus {:?} color should not be black", + colony_ui::accent_key_to_color(s.accent_key()).is_some(), + "GameStatus {:?} names an accent colony-ui does not ship", s ); } + let colors: Vec<_> = GameStatus::all().iter().map(|s| s.color()).collect(); + for (i, a) in colors.iter().enumerate() { + for b in &colors[i + 1..] { + assert_ne!(a, b, "two statuses share a colour"); + } + } } -// ───── Settings favorites serialization ───── +// ───── Preferences favorites serialization ───── #[test] -fn settings_favorites_serde_roundtrip() { - let settings = spotter::models::Settings { +fn preferences_favorites_serde_roundtrip() { + let prefs = spotter::models::Preferences { favorites: vec![1, 42, 100], ..Default::default() }; - let json = serde_json::to_string(&settings).unwrap(); - let parsed: spotter::models::Settings = serde_json::from_str(&json).unwrap(); + let json = serde_json::to_string(&prefs).unwrap(); + let parsed: spotter::models::Preferences = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.favorites, vec![1, 42, 100]); } #[test] -fn settings_favorites_default_empty() { - let settings = spotter::models::Settings::default(); - assert!(settings.favorites.is_empty()); +fn preferences_favorites_default_empty() { + let prefs = spotter::models::Preferences::default(); + assert!(prefs.favorites.is_empty()); } -// ───── SortOrder FavoritesFirst display ───── +// ───── SortOrder FavoritesFirst label ───── #[test] -fn sort_order_favorites_first_display() { +fn sort_order_favorites_first_label() { assert_eq!( - format!("{}", spotter::models::SortOrder::FavoritesFirst), - "Favorites" + spotter::models::SortOrder::FavoritesFirst.label(), + "Favorites first" ); }