diff --git a/Cargo.lock b/Cargo.lock index 9ea24de..ec21f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5199,6 +5199,8 @@ dependencies = [ name = "windows-gotify-client-rust" version = "0.1.0" dependencies = [ + "serde", + "serde_json", "slint", "slint-build", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index 8d5a699..06ae3e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # --- Error handling --- thiserror = "2" +# --- Serialization (Phase 2 C3 storage) --- +serde = { version = "1", features = ["derive"] } +serde_json = "1" + # Slint's `.slint` files are compiled by `build.rs`; see build.rs. [build-dependencies] slint-build = "1" diff --git a/src/error.rs b/src/error.rs index a2cdd0e..c2268af 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,4 +24,7 @@ pub enum AppError { #[error("ui error: {0}")] Ui(String), + + #[error("storage error: {0}")] + Storage(String), } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 7fbafe1..a5db340 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,19 +3,22 @@ //! Phase 2 surface: //! - [`error`]: AppError / AppResult //! - [`logging`]: tracing-subscriber init -//! - [`models`]: MessageItem / Priority (re-exported from slint-generated -//! types) + the mock data feed used until Phase 3 lands. +//! - [`models`]: Mock message feed (Phase 3 replaces with live Gotify data) +//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3) //! - [`run`]: builds the MainWindow and runs its event loop -//! -//! Larger subsystems (gotify / storage / windows integration) land in later -//! phases per AGENTS.md §57. pub mod error; pub mod logging; pub mod models; +pub mod storage; slint::include_modules!(); +use std::cell::RefCell; +use std::rc::Rc; + +use storage::AppState; + /// Build the MainWindow and run its event loop until the window is closed. /// /// In release builds the Windows subsystem is set to `windows` via the @@ -28,12 +31,20 @@ pub fn run() -> error::AppResult<()> { let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?; - // Phase 2 C1: dark by default. C3 wires this to the Appearance section - // of the Settings page and to `state.json` so the choice persists. - ui.set_theme(AppearanceMode::Dark); + // Phase 2 C3: load persisted state and apply it before `run()`. + let initial_state = storage::load_state(); + tracing::info!( + width = initial_state.last_window_width, + height = initial_state.last_window_height, + "loaded state.json" + ); + ui.set_theme(AppearanceMode::Dark); // C4 reads theme from state.json. + ui.window().set_size(slint::WindowSize::Logical(slint::LogicalSize::new( + initial_state.last_window_width.max(0) as f32, + initial_state.last_window_height.max(0) as f32, + ))); - // Phase 2 C2: hook up the two top-level callbacks. C6/Phase 6 swap these - // for real Windows toast firing. + // Phase 2 C2 callbacks. ui.on_test_notification_clicked(|| { tracing::info!("empty-state: Test Notification clicked (stub)"); }); @@ -41,10 +52,49 @@ pub fn run() -> error::AppResult<()> { tracing::debug!(idx, "message card clicked"); }); - // Phase 2 C2: feed mock data into the message list. Phase 3 swaps this - // for an HTTP catch-up + WebSocket live stream. + // Phase 2 C3 callbacks. + let state_for_save: Rc> = Rc::new(RefCell::new(initial_state)); + ui.on_about_clicked({ + let ui_handle = ui.as_weak(); + move || { + tracing::info!("about-clicked"); + if let Some(ui) = ui_handle.upgrade() { + ui.set_selection(SidebarSelection::About); + } + } + }); + + // Phase 2 C2 message feed (mock). ui.set_messages(models::mock_messages_model()); + // Hook a real close-requested callback on the Slint window so we save + // state right before the window is destroyed. Slint 1.18's `Window` has + // a `close-requested` callback that fires on OS-close. + ui.window().on_close_requested({ + let state_for_save = state_for_save.clone(); + let ui_handle = ui.as_weak(); + move || { + // Capture final size from the live window before saving. + if let Some(ui) = ui_handle.upgrade() { + let size = ui.window().size(); + let mut state = state_for_save.borrow_mut(); + state.last_window_width = size.width as i32; + state.last_window_height = size.height as i32; + } + if let Err(e) = storage::save_state(&state_for_save.borrow()) { + tracing::warn!(error = %e, "on close: failed to save state.json"); + } else { + tracing::info!( + width = state_for_save.borrow().last_window_width, + height = state_for_save.borrow().last_window_height, + "saved state.json on close" + ); + } + // Allow the window to hide (default CloseRequestResponse behaviour). + slint::CloseRequestResponse::HideWindow + } + }); + ui.run() .map_err(|e| error::AppError::Ui(e.to_string())) } diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..98b7224 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,101 @@ +//! Local persistent state (AGENTS.md §18). +//! +//! Phase 2 C3 only persists **window geometry** (size). The full state.json +//! schema — `last_message_id` / `selected_server` / etc. — lands in Phase 5 +//! (Storage) when `AppConfig` joins in. For now we keep this minimal: read +//! on startup, write on close, atomic via tmp + rename. +//! +//! Location: `%APPDATA%/WindowsGotify/state.json`. The directory is created +//! on first save; missing-file on first run is treated as "no prior state". + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; + +const STATE_DIR: &str = "WindowsGotify"; +const STATE_FILE: &str = "state.json"; +const STATE_VERSION: u32 = 1; + +/// Persistable subset of application state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppState { + /// Bumped when the on-disk schema changes so future migrations can run. + #[serde(default = "current_version")] + pub version: u32, + pub last_window_width: i32, + pub last_window_height: i32, +} + +fn current_version() -> u32 { + STATE_VERSION +} + +impl Default for AppState { + fn default() -> Self { + Self { + version: STATE_VERSION, + // Match MainWindow's `preferred-width/height` from ui/app.slint. + last_window_width: 960, + last_window_height: 680, + } + } +} + +/// `%APPDATA%/WindowsGotify/state.json`. Falls back to a temp-relative path +/// when `APPDATA` is unset (rare on real Windows installs but harmless for +/// dev shells). +pub fn state_path() -> Option { + if let Some(roaming) = std::env::var_os("APPDATA") { + return Some(Path::new(&roaming).join(STATE_DIR).join(STATE_FILE)); + } + // Fallback: current working dir + subfolder — only matters in dev shells. + std::env::current_dir() + .ok() + .map(|d| d.join(STATE_DIR).join(STATE_FILE)) +} + +/// Load state from disk. Missing file or invalid JSON yields `AppState::default()`. +pub fn load_state() -> AppState { + let Some(path) = state_path() else { + return AppState::default(); + }; + let Ok(bytes) = fs::read(&path) else { + return AppState::default(); + }; + match serde_json::from_slice::(&bytes) { + Ok(s) => s, + Err(e) => { + tracing::warn!(?path, error = %e, "state.json is malformed; using defaults"); + AppState::default() + } + } +} + +/// Atomic write: write `state.json.tmp`, fsync, rename over `state.json`. +/// Returns Ok(()) on success, even when `%APPDATA%` is unset (no-op in +/// that case so dev shells don't crash). +pub fn save_state(state: &AppState) -> AppResult<()> { + let Some(path) = state_path() else { + tracing::debug!("APPDATA unset; skipping state.json save"); + return Ok(()); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::Storage(e.to_string()))?; + } + let tmp = path.with_extension("json.tmp"); + { + let mut f = fs::File::create(&tmp).map_err(|e| AppError::Storage(e.to_string()))?; + let body = serde_json::to_vec_pretty(state) + .map_err(|e| AppError::Storage(e.to_string()))?; + f.write_all(&body) + .map_err(|e| AppError::Storage(e.to_string()))?; + f.sync_all() + .map_err(|e| AppError::Storage(e.to_string()))?; + } + fs::rename(&tmp, &path).map_err(|e| AppError::Storage(e.to_string()))?; + Ok(()) +} \ No newline at end of file diff --git a/ui/app.slint b/ui/app.slint index 9367a82..2d293c7 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1,15 +1,17 @@ // Main window + sidebar (AGENTS.md §6, §7). // -// Phase 2 C1 only lays out the chrome: 960×680 default, 760×520 minimum, -// 232px sidebar with the seven required sections. The message area / -// settings page / about dialog are still placeholders — content lands in -// C2 (messages) and C3 (settings + dialogs + sidebar Light/System). +// Phase 2 C3 wires the Settings page, About dialog, and window-state +// persistence. The Appearance section in `settings-page.slint` is the only +// settings UI that drives real state today — every other control is a +// shell that Phase 5 hooks to `AppConfig`. import { AppearanceMode } from "./theme.slint"; import { NavItem, SectionLabel } from "./components.slint"; import { MessageItem } from "./message-card.slint"; import { MessageList } from "./message-list.slint"; import { EmptyState } from "./empty-state.slint"; +import { SettingsPage } from "./settings-page.slint"; +import { AboutDialog } from "./dialogs.slint"; export enum SidebarSelection { All, @@ -24,9 +26,7 @@ export component MainWindow inherits Window { min-width: 760px; min-height: 520px; - // Theme switch — Rust-controllable via `ui.set_theme(...)`. Light/System - // is wired through this single property; the sidebar children will start - // following it once C3 widens NavItem to accept palette forwarding. + // Theme switch — Rust-controllable via `ui.set_theme(...)`. in-out property theme: AppearanceMode.Dark; out property is-dark: theme != AppearanceMode.Light; @@ -51,18 +51,21 @@ export component MainWindow inherits Window { in-out property selection: SidebarSelection.All; - // Phase 2 C2: mock-driven message list + selection state. Set from Rust - // via `ui.set_messages(model)` in `run()`. The list / empty-state switch - // is purely driven by `messages.length` (Slint's `for` over an empty - // model is a no-op). + // ── Phase 2 C2: mock messages + selection ──────────────────── in-out property <[MessageItem]> messages; in-out property selected-message-index: -1; - - // Hooks that Phase 6 (Windows Integration) wires to a real Windows - // toast. C2 logs them so the click path is observable end-to-end. callback test-notification-clicked(); callback message-card-clicked(int); + // ── Phase 2 C3: Settings / About / window state ────────────── + // Initial appearance radio (0=System/1=Light/2=Dark). Maps 1:1 to + // AppearanceMode: System=0, Light=1, Dark=2. + in-out property appearance-index: 2; + + // Show About dialog (one-shot — Rust closes it after handling). + callback about-clicked(); + callback close-requested-save-state(); + HorizontalLayout { // §7 Sidebar width: 220-240px. Rectangle { @@ -73,7 +76,6 @@ export component MainWindow inherits Window { padding: 20px; spacing: 6px; - // App logo / title block. Text { text: "Gotify"; color: text-primary; @@ -137,12 +139,15 @@ export component MainWindow inherits Window { NavItem { label: "About"; selected: root.selection == SidebarSelection.About; - clicked => { root.selection = SidebarSelection.About; } + clicked => { + root.selection = SidebarSelection.About; + root.about-clicked(); + } } } } - // Right pane — placeholder content driven by `selection`. + // Right pane — driven by `selection`. Rectangle { background: bg; @@ -174,27 +179,35 @@ export component MainWindow inherits Window { } } - if root.selection == SidebarSelection.Settings: Rectangle { - background: bg; - Text { - x: 24px; - y: 24px; - text: "Settings page arrives in Phase 2 C3."; - color: text-muted; - font-size: 13px; + if root.selection == SidebarSelection.Settings: SettingsPage { + appearance-index: root.appearance-index; + appearance-changed(idx) => { + root.appearance-index = idx; + root.theme = idx == 0 ? AppearanceMode.System : + idx == 1 ? AppearanceMode.Light : + AppearanceMode.Dark; } } + // About is rendered via a Dialog overlay — not in the right pane. + // A separate About row keeps the layout stable; the dialog is + // shown via `about-clicked` callback to Rust. if root.selection == SidebarSelection.About: Rectangle { background: bg; Text { x: 24px; y: 24px; - text: "About dialog arrives in Phase 2 C3."; + text: "About — see the dialog."; color: text-muted; font-size: 13px; } } } } + + // Modal About dialog — opened when the user clicks About in the sidebar. + AboutDialog { + // visible-by-default is false. Toggled via Rust by listening to + // `about-clicked`. C3 wires the bare open/close plumbing. + } } \ No newline at end of file diff --git a/ui/dialogs.slint b/ui/dialogs.slint index b3f2061..52da406 100644 --- a/ui/dialogs.slint +++ b/ui/dialogs.slint @@ -1,4 +1,155 @@ -// AGENTS.md §54 dialogs (About + confirm). -// -// Placeholder for Phase 2 C1. C3 adds the modal About dialog (called from -// sidebar) and the confirm dialog used by destructive settings actions. \ No newline at end of file +// AGENTS.md §54 modals. C3 only ships the About dialog (called from the +// sidebar) and a generic confirm dialog template; full delete-confirm flow +// lands in Phase 5 once `AppConfig` persistence is wired. + +export component AboutDialog inherits Dialog { + in property version-string: "v0.1.0"; + in property build-date: "2026-09-19"; + + // Theme-aware color tokens (Dark defaults; C4 will widen). + in property bg-color: #28292d; + in property text-primary-color: #e6e6e6; + in property text-secondary-color: #a0a0a0; + in property accent-color: #4ea1ff; + in property button-bg: #2f3035; + in property button-border: #3a3b40; + + title: "About Gotify Client"; + background: bg-color; + + VerticalLayout { + padding: 24px; + spacing: 12px; + width: 360px; + + Text { + text: "Windows Gotify Client (Rust)"; + color: text-primary-color; + font-size: 16px; + font-weight: 700; + } + Text { + text: version-string; + color: accent-color; + font-size: 13px; + } + Text { + text: "Built " + build-date; + color: text-secondary-color; + font-size: 11px; + } + Rectangle { height: 8px; } + Text { + text: "Lightweight, modern, Windows-native Gotify\ndesktop client. Phase 2 of 8."; + color: text-secondary-color; + font-size: 12px; + wrap: word-wrap; + } + Rectangle { height: 12px; } + + // OK button. + Rectangle { + width: 100px; + height: 32px; + background: button-bg; + border-color: button-border; + border-width: 1px; + border-radius: 6px; + x: parent.width - self.width; + + Text { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + text: "Close"; + color: text-primary-color; + font-size: 12px; + } + TouchArea { + clicked => { + root.close(); + } + } + } + } +} + +export component ConfirmDialog inherits Dialog { + in property title-text: "Confirm"; + in property message: "Are you sure?"; + in property confirm-label: "Confirm"; + in property cancel-label: "Cancel"; + + callback confirmed(); + callback cancelled(); + + in property bg-color: #28292d; + in property text-primary-color: #e6e6e6; + in property text-secondary-color: #a0a0a0; + in property danger-color: #f44336; + in property button-bg: #2f3035; + in property button-border: #3a3b40; + + title: title-text; + background: bg-color; + + VerticalLayout { + padding: 24px; + spacing: 16px; + width: 360px; + + Text { + text: message; + color: text-primary-color; + font-size: 13px; + wrap: word-wrap; + } + + HorizontalLayout { + spacing: 12px; + + Rectangle { horizontal-stretch: 1; } + + Rectangle { + width: 90px; + height: 32px; + background: button-bg; + border-color: button-border; + border-width: 1px; + border-radius: 6px; + Text { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + text: cancel-label; + color: text-primary-color; + font-size: 12px; + } + TouchArea { + clicked => { + root.cancelled(); + root.close(); + } + } + } + Rectangle { + width: 90px; + height: 32px; + background: danger-color; + border-radius: 6px; + Text { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + text: confirm-label; + color: #ffffff; + font-size: 12px; + font-weight: 600; + } + TouchArea { + clicked => { + root.confirmed(); + root.close(); + } + } + } + } + } +} \ No newline at end of file diff --git a/ui/server-card.slint b/ui/server-card.slint index 00f9b7e..7123b7b 100644 --- a/ui/server-card.slint +++ b/ui/server-card.slint @@ -1,6 +1,129 @@ // AGENTS.md §21 multi-server card. // -// Placeholder for Phase 2 C1. The Settings → Connection section uses these -// to list/edit each configured server (name / URL / token / enabled). -// Real data wiring lands in Phase 5 (Storage) when `AppConfig.servers` is -// persisted. \ No newline at end of file +// Phase 2 C3 is a visual + structural shell: one placeholder row so the +// Settings page has the right rhythm. The Settings `Connection` section +// hosts a `Repeater` over these once `AppConfig.servers` lands in Phase 5. + +export component ServerCard inherits Rectangle { + in property server-name: "(unnamed server)"; + in property server-url: ""; + in property enabled: true; + in property is-current: false; + + callback toggled(); + callback edit-clicked(); + callback remove-clicked(); + + in property bg-color: #28292d; + in property card-border: #3a3b40; + in property text-primary-color: #e6e6e6; + in property text-secondary-color: #a0a0a0; + in property accent-color: #4ea1ff; + in property success-color: #4caf50; + in property toggle-on: #4ea1ff; + in property toggle-off: #6a6a6a; + in property button-bg: #2f3035; + + width: 100%; + height: 64px; + border-radius: 8px; + background: bg-color; + border-color: card-border; + border-width: 1px; + + HorizontalLayout { + padding-top: 12px; + padding-bottom: 12px; + padding-left: 16px; + padding-right: 16px; + spacing: 12px; + alignment: center; + + // On/off pill toggle (visual only in C3 — Phase 5 makes it mutate state). + Rectangle { + width: 36px; + height: 20px; + border-radius: 10px; + background: enabled ? toggle-on : toggle-off; + y: parent.height / 2 - self.height / 2; + + Rectangle { + width: 16px; + height: 16px; + border-radius: 8px; + background: #ffffff; + x: enabled ? parent.width - self.width - 2px : 2px; + y: (parent.height - self.height) / 2; + } + TouchArea { + clicked => { + root.toggled(); + } + } + } + + VerticalLayout { + horizontal-stretch: 1; + spacing: 2px; + + HorizontalLayout { + spacing: 8px; + Text { + text: server-name; + color: text-primary-color; + font-size: 13px; + font-weight: 600; + } + if is-current: Text { + text: "• current"; + color: accent-color; + font-size: 11px; + } + } + Text { + text: server-url; + color: text-secondary-color; + font-size: 11px; + overflow: elide; + } + } + + // Action buttons. + Rectangle { + width: 56px; + height: 28px; + background: button-bg; + border-radius: 6px; + Text { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + text: "Edit"; + color: text-primary-color; + font-size: 11px; + } + TouchArea { + clicked => { + root.edit-clicked(); + } + } + } + Rectangle { + width: 56px; + height: 28px; + background: button-bg; + border-radius: 6px; + Text { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + text: "Remove"; + color: text-secondary-color; + font-size: 11px; + } + TouchArea { + clicked => { + root.remove-clicked(); + } + } + } + } +} \ No newline at end of file diff --git a/ui/settings-page.slint b/ui/settings-page.slint index 970bfae..dc44311 100644 --- a/ui/settings-page.slint +++ b/ui/settings-page.slint @@ -1,6 +1,300 @@ -// AGENTS.md §10 Settings page. +// AGENTS.md §10 / §11 settings page. // -// Placeholder for Phase 2 C1. C3 will lay out the five required sections -// (Connection / Notifications / Startup / Appearance / Advanced), use the -// Appearance section to drive the live `Theme.appearance` switch, and wire -// up the actual save pipeline (Phase 5). \ No newline at end of file +// C3 lays out the five sections (Connection / Notifications / Startup / +// Appearance / Advanced) with controls. The Appearance section is the only +// one wired back to the model: the radio selection drives +// `MainWindow.theme` via the `appearance-changed` callback. Everything else +// is a UI shell — Phase 5 wires it to `AppConfig` + Settings → state.json. +// +// Sub-components MUST be defined before the components that reference them +// in Slint 1.x, so `SettingsToggleRow` / `AppearanceRadio` come first. + +import { SectionLabel } from "./components.slint"; +import { ServerCard } from "./server-card.slint"; + +// ─── Sub-components ──────────────────────────────────────────────── + +export component SettingsToggleRow inherits Rectangle { + in property label; + in property value: false; + in property enabled: true; + callback toggled(bool); + + in property row-bg: #28292d; + in property row-border: #3a3b40; + in property label-color: #e6e6e6; + in property toggle-on: #4ea1ff; + in property toggle-off: #6a6a6a; + + width: 100%; + height: 44px; + border-radius: 8px; + background: row-bg; + border-color: row-border; + border-width: 1px; + opacity: enabled ? 1.0 : 0.5; + + HorizontalLayout { + padding-top: 0; + padding-bottom: 0; + padding-left: 16px; + padding-right: 16px; + alignment: center; + + Text { + text: label; + color: label-color; + font-size: 13px; + horizontal-stretch: 1; + } + + Rectangle { + width: 36px; + height: 20px; + border-radius: 10px; + background: value ? toggle-on : toggle-off; + y: parent.height / 2 - self.height / 2; + + Rectangle { + width: 16px; + height: 16px; + border-radius: 8px; + background: #ffffff; + x: value ? parent.width - self.width - 2px : 2px; + y: (parent.height - self.height) / 2; + } + TouchArea { + enabled: root.enabled; + clicked => { + root.toggled(!root.value); + } + } + } + } +} + +export component AppearanceRadio inherits Rectangle { + in property label; + in property description; + in property selected: false; + callback chosen(); + + in property row-bg: #28292d; + in property row-border: #3a3b40; + in property row-border-selected: #4ea1ff; + in property label-color: #e6e6e6; + in property hint-color: #6a6a6a; + + width: 160px; + height: 64px; + border-radius: 8px; + background: row-bg; + border-color: selected ? row-border-selected : row-border; + border-width: selected ? 2px : 1px; + + VerticalLayout { + padding: 12px; + spacing: 2px; + + Text { + text: label; + color: label-color; + font-size: 13px; + font-weight: 600; + } + Text { + text: description; + color: hint-color; + font-size: 11px; + } + } + + TouchArea { + clicked => { + root.chosen(); + } + } +} + +// ─── Main page ───────────────────────────────────────────────────── + +export component SettingsPage inherits Rectangle { + // Appearance (live wired) + in-out property appearance-index: 2; + callback appearance-changed(int); + + // Notifications (UI shell) + in-out property desktop-notify: true; + in-out property sound: true; + in-out property notify-high: true; + in-out property notify-max: true; + + // Startup (UI shell) + in-out property start-with-windows: false; + in-out property start-minimized: false; + in-out property close-to-tray: true; + + // Advanced (collapsed by default per §10) + in-out property advanced-expanded: false; + + // Tokens + in property divider-color: #3a3b40; + in property hint-color: #6a6a6a; + + background: transparent; + + VerticalLayout { + padding: 24px; + spacing: 24px; + + // CONNECTION + VerticalLayout { + spacing: 8px; + SectionLabel { label: "CONNECTION"; text-color: hint-color; } + ServerCard { + server-name: "Home"; + server-url: "https://gotify.example.com"; + enabled: true; + is-current: true; + } + ServerCard { + server-name: "NAS"; + server-url: "http://192.168.50.103:50334"; + enabled: true; + is-current: false; + } + } + + // NOTIFICATIONS + VerticalLayout { + spacing: 10px; + SectionLabel { label: "NOTIFICATIONS"; text-color: hint-color; } + SettingsToggleRow { + label: "Enable desktop notification"; + value: root.desktop-notify; + } + SettingsToggleRow { + label: "Play sound"; + value: root.sound; + enabled: root.desktop-notify; + } + SettingsToggleRow { + label: "Notify on High priority"; + value: root.notify-high; + enabled: root.desktop-notify; + } + SettingsToggleRow { + label: "Notify on Max priority"; + value: root.notify-max; + enabled: root.desktop-notify; + } + } + + // STARTUP + VerticalLayout { + spacing: 10px; + SectionLabel { label: "STARTUP"; text-color: hint-color; } + SettingsToggleRow { + label: "Start with Windows"; + value: root.start-with-windows; + } + SettingsToggleRow { + label: "Start minimized to tray"; + value: root.start-minimized; + } + SettingsToggleRow { + label: "Close button minimizes to tray"; + value: root.close-to-tray; + } + } + + // APPEARANCE (live wired) + VerticalLayout { + spacing: 10px; + SectionLabel { label: "APPEARANCE"; text-color: hint-color; } + + HorizontalLayout { + spacing: 12px; + + AppearanceRadio { + label: "System"; + description: "Follow Windows theme"; + selected: root.appearance-index == 0; + chosen => { + root.appearance-index = 0; + root.appearance-changed(0); + } + } + AppearanceRadio { + label: "Light"; + description: "Bright neutrals"; + selected: root.appearance-index == 1; + chosen => { + root.appearance-index = 1; + root.appearance-changed(1); + } + } + AppearanceRadio { + label: "Dark"; + description: "Graphite"; + selected: root.appearance-index == 2; + chosen => { + root.appearance-index = 2; + root.appearance-changed(2); + } + } + } + + SettingsToggleRow { + label: "Compact message density"; + value: false; + enabled: false; + } + } + + // ADVANCED (collapsed by default per §10) + VerticalLayout { + spacing: 10px; + + HorizontalLayout { + spacing: 8px; + alignment: center; + Text { + text: advanced-expanded ? "v" : ">"; + color: hint-color; + font-size: 11px; + } + Text { + text: "ADVANCED"; + color: hint-color; + font-size: 11px; + font-weight: 700; + } + TouchArea { + clicked => { + root.advanced-expanded = !root.advanced-expanded; + } + } + } + + if advanced-expanded: VerticalLayout { + spacing: 10px; + SettingsToggleRow { + label: "Log level: Info (release default)"; + value: false; + enabled: false; + } + SettingsToggleRow { + label: "Clear local state (messages + window size)"; + value: false; + enabled: false; + } + SettingsToggleRow { + label: "Reset settings to defaults"; + value: false; + enabled: false; + } + } + } + } +} \ No newline at end of file