feat(gotify): Phase 3 C2 — UI 接线 + Connection status + 真实数据
- src/config.rs: AppConfig::placeholder() 硬编码 NAS 地址 + 假 token (§20 token 永不日志;Phase 5 接持久化前占位) - src/models.rs: gotify_messages_to_items 把 GotifyMessage 列表转 Slint MessageItem[];try_fetch_real_messages 一次完成 health → applications → messages(50);失败 None 让调用方 fallback mock - src/gotify/mod.rs: re-export ServerConfig - src/lib.rs: run() 启动时调 try_fetch_real_messages,成功用真实数 据 + Connected + 服务端 URL,失败 fallback mock + Disconnected; on_test_connection_requested 重新跑同一路径 - Cargo.toml: 加 chrono 0.4 (RFC3339 解析) - ui/app.slint: is-connected (绿/灰 dot) + status-text (顶部副标) - ui/settings-page.slint: Connection section 加 Test Connection 按钮 (in-progress 时禁用显示 "Testing...") 避坑: - Gotify RFC3339 date 解析失败 silent fallback 到 unix=0(显示 "just now" 而不是崩) - Gotify priority 0..10 → Slint Priority 4 桶分桶:<=0 Low / <=3 Normal / <=7 High / else Max - ServerConfig 在 crate::gotify::model 子模块,mod.rs 必须 re-export 验证:cargo build / build --release 都 exit 0,0 warning - cargo test --test gotify_api: 6 passed - debug 33.36 MB, subsystem = WINDOWS_CUI - release 9.43 MB, subsystem = WINDOWS_GUI(chrono +1.3 MB)
This commit is contained in:
Generated
+2
@@ -694,6 +694,7 @@ dependencies = [
|
|||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -5681,6 +5682,7 @@ dependencies = [
|
|||||||
name = "windows-gotify-client-rust"
|
name = "windows-gotify-client-rust"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ serde_json = "1"
|
|||||||
# --- Networking (Phase 3 Gotify HTTP) ---
|
# --- Networking (Phase 3 Gotify HTTP) ---
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
|
||||||
|
# --- Date/time (Phase 3 C2 convert Gotify RFC3339) ---
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
|
|
||||||
# Slint's `.slint` files are compiled by `build.rs`; see build.rs.
|
# Slint's `.slint` files are compiled by `build.rs`; see build.rs.
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
slint-build = "1"
|
slint-build = "1"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//! Application configuration placeholder.
|
||||||
|
//!
|
||||||
|
//! Phase 3 ships only a hardcoded `placeholder()` so the UI wiring can
|
||||||
|
//! demonstrate the real-data path. Phase 5 replaces this with persistent
|
||||||
|
//! JSON storage at `%APPDATA%/WindowsGotify/config.json` (AGENTS.md §5/§19/§20).
|
||||||
|
//!
|
||||||
|
//! AGENTS.md §20 — the placeholder token below is intentionally not a
|
||||||
|
//! real credential. Real auth fails, the UI falls back to mock data, and
|
||||||
|
//! nothing about the token ever reaches a log line.
|
||||||
|
|
||||||
|
use crate::gotify::ServerConfig;
|
||||||
|
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub server: Option<ServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Hardcoded single-server placeholder. Returns `None` here means
|
||||||
|
/// "no server configured" — but for now Phase 3 always returns `Some`
|
||||||
|
/// so the UI wiring is exercised on every start.
|
||||||
|
pub fn placeholder() -> Self {
|
||||||
|
Self {
|
||||||
|
server: Some(ServerConfig::new(
|
||||||
|
"http://192.168.50.103:50334",
|
||||||
|
"placeholder-token-do-not-log",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -11,4 +11,4 @@ pub mod api;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
|
|
||||||
pub use api::GotifyApi;
|
pub use api::GotifyApi;
|
||||||
pub use model::{GotifyApplication, GotifyHealth, GotifyMessage};
|
pub use model::{GotifyApplication, GotifyHealth, GotifyMessage, ServerConfig};
|
||||||
+64
-13
@@ -1,12 +1,15 @@
|
|||||||
//! Windows Gotify Client (Rust) — core library.
|
//! Windows Gotify Client (Rust) — core library.
|
||||||
//!
|
//!
|
||||||
//! Phase 2 surface:
|
//! Phase 3 surface:
|
||||||
|
//! - [`config`]: placeholder AppConfig (Phase 5 swaps for persistence)
|
||||||
//! - [`error`]: AppError / AppResult
|
//! - [`error`]: AppError / AppResult
|
||||||
|
//! - [`gotify`]: blocking REST client (health / applications / messages)
|
||||||
//! - [`logging`]: tracing-subscriber init
|
//! - [`logging`]: tracing-subscriber init
|
||||||
//! - [`models`]: Mock message feed (Phase 3 replaces with live Gotify data)
|
//! - [`models`]: mock feed + Gotify→Slint adapter
|
||||||
//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3)
|
//! - [`storage`]: `%APPDATA%/WindowsGotify/state.json` (Phase 2 C3)
|
||||||
//! - [`run`]: builds the MainWindow and runs its event loop
|
//! - [`run`]: builds the MainWindow and runs its event loop
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod gotify;
|
pub mod gotify;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
@@ -32,7 +35,7 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
|
|
||||||
let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?;
|
let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?;
|
||||||
|
|
||||||
// Phase 2 C3: load persisted state and apply it before `run()`.
|
// ── Load persisted window geometry (Phase 2 C3) ───────────────
|
||||||
let initial_state = storage::load_state();
|
let initial_state = storage::load_state();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
width = initial_state.last_window_width,
|
width = initial_state.last_window_width,
|
||||||
@@ -45,7 +48,30 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
initial_state.last_window_height.max(0) as f32,
|
initial_state.last_window_height.max(0) as f32,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// Phase 2 C2 callbacks.
|
// ── Phase 3 C2: try the real Gotify server, fall back to mock ──
|
||||||
|
let cfg = config::AppConfig::placeholder();
|
||||||
|
let (messages_model, status_text, is_connected) = match cfg.server.as_ref() {
|
||||||
|
Some(server) => match models::try_fetch_real_messages(&server.base_url, &server.token) {
|
||||||
|
Some((model, text)) => {
|
||||||
|
tracing::info!(server = %server.base_url, "connected to real Gotify");
|
||||||
|
(model, text, true)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(server = %server.base_url, "real Gotify unreachable; using mock data");
|
||||||
|
let text = format!("Disconnected · mock feed · {}", server.base_url);
|
||||||
|
(models::mock_messages_model(), text, false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tracing::info!("no server configured; using mock data");
|
||||||
|
(models::mock_messages_model(), "No server configured · mock feed".to_string(), false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ui.set_messages(messages_model);
|
||||||
|
ui.set_status_text(status_text.into());
|
||||||
|
ui.set_is_connected(is_connected);
|
||||||
|
|
||||||
|
// ── Phase 2 C2 callbacks ───────────────────────────────────────
|
||||||
ui.on_test_notification_clicked(|| {
|
ui.on_test_notification_clicked(|| {
|
||||||
tracing::info!("empty-state: Test Notification clicked (stub)");
|
tracing::info!("empty-state: Test Notification clicked (stub)");
|
||||||
});
|
});
|
||||||
@@ -53,7 +79,39 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
tracing::debug!(idx, "message card clicked");
|
tracing::debug!(idx, "message card clicked");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phase 2 C3 callbacks.
|
// ── Phase 3 C2: Settings → Connection → Test button ───────────
|
||||||
|
ui.on_test_connection_requested({
|
||||||
|
let ui_handle = ui.as_weak();
|
||||||
|
move || {
|
||||||
|
// Re-run the same fetch path; UI already shows "Disconnected"
|
||||||
|
// so this is the only way the user can poke the server.
|
||||||
|
tracing::info!("settings: Test Connection clicked");
|
||||||
|
let Some(server) = config::AppConfig::placeholder().server else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match models::try_fetch_real_messages(&server.base_url, &server.token) {
|
||||||
|
Some((model, text)) => {
|
||||||
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
|
ui.set_messages(model);
|
||||||
|
ui.set_status_text(text.into());
|
||||||
|
ui.set_is_connected(true);
|
||||||
|
}
|
||||||
|
tracing::info!("Test Connection: ok");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
|
ui.set_status_text(
|
||||||
|
format!("Disconnected · mock feed · {}", server.base_url).into(),
|
||||||
|
);
|
||||||
|
ui.set_is_connected(false);
|
||||||
|
}
|
||||||
|
tracing::warn!("Test Connection: failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 2 C3 callbacks ───────────────────────────────────────
|
||||||
let state_for_save: Rc<RefCell<AppState>> = Rc::new(RefCell::new(initial_state));
|
let state_for_save: Rc<RefCell<AppState>> = Rc::new(RefCell::new(initial_state));
|
||||||
ui.on_about_clicked({
|
ui.on_about_clicked({
|
||||||
let ui_handle = ui.as_weak();
|
let ui_handle = ui.as_weak();
|
||||||
@@ -65,17 +123,11 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phase 2 C2 message feed (mock).
|
// ── Phase 2 C3: save state on OS-close ────────────────────────
|
||||||
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({
|
ui.window().on_close_requested({
|
||||||
let state_for_save = state_for_save.clone();
|
let state_for_save = state_for_save.clone();
|
||||||
let ui_handle = ui.as_weak();
|
let ui_handle = ui.as_weak();
|
||||||
move || {
|
move || {
|
||||||
// Capture final size from the live window before saving.
|
|
||||||
if let Some(ui) = ui_handle.upgrade() {
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
let size = ui.window().size();
|
let size = ui.window().size();
|
||||||
let mut state = state_for_save.borrow_mut();
|
let mut state = state_for_save.borrow_mut();
|
||||||
@@ -91,7 +143,6 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
"saved state.json on close"
|
"saved state.json on close"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Allow the window to hide (default CloseRequestResponse behaviour).
|
|
||||||
slint::CloseRequestResponse::HideWindow
|
slint::CloseRequestResponse::HideWindow
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use chrono::DateTime;
|
||||||
use slint::{ModelRc, VecModel};
|
use slint::{ModelRc, VecModel};
|
||||||
|
|
||||||
use crate::MessageItem;
|
use crate::MessageItem;
|
||||||
|
use crate::gotify::{GotifyApplication, GotifyMessage};
|
||||||
|
|
||||||
/// Wall-clock now, as Unix seconds. Falls back to 0 if the system clock is
|
/// Wall-clock now, as Unix seconds. Falls back to 0 if the system clock is
|
||||||
/// somehow pre-1970, which should never happen on a real Windows install.
|
/// somehow pre-1970, which should never happen on a real Windows install.
|
||||||
@@ -154,3 +156,83 @@ pub fn mock_messages() -> Vec<MessageItem> {
|
|||||||
pub fn mock_messages_model() -> ModelRc<MessageItem> {
|
pub fn mock_messages_model() -> ModelRc<MessageItem> {
|
||||||
ModelRc::from(std::rc::Rc::new(VecModel::from(mock_messages())))
|
ModelRc::from(std::rc::Rc::new(VecModel::from(mock_messages())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Try to parse a Gotify RFC3339 timestamp; fall back to 0 on failure.
|
||||||
|
fn gotify_date_to_unix(date: &str) -> i32 {
|
||||||
|
DateTime::parse_from_rfc3339(date)
|
||||||
|
.map(|dt| dt.timestamp() as i32)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one Gotify wire message + its resolved application name into the
|
||||||
|
/// Slint-facing `MessageItem`. `app_name` is resolved separately from the
|
||||||
|
/// application list (caller joins).
|
||||||
|
pub fn gotify_message_to_item(
|
||||||
|
msg: &GotifyMessage,
|
||||||
|
apps_by_id: &std::collections::HashMap<i64, GotifyApplication>,
|
||||||
|
) -> MessageItem {
|
||||||
|
let app_name = apps_by_id
|
||||||
|
.get(&msg.appid)
|
||||||
|
.map(|a| a.name.clone())
|
||||||
|
.unwrap_or_else(|| format!("App #{}", msg.appid));
|
||||||
|
let unix = gotify_date_to_unix(&msg.date);
|
||||||
|
MessageItem {
|
||||||
|
id: msg.id as i32,
|
||||||
|
app_id: msg.appid as i32,
|
||||||
|
app_name: app_name.into(),
|
||||||
|
title: msg.title.clone().into(),
|
||||||
|
body: msg.message.clone().into(),
|
||||||
|
priority: map_slint(msg.priority),
|
||||||
|
timestamp_unix: unix,
|
||||||
|
relative_time: format_relative(unix as i64, now_unix()).into(),
|
||||||
|
has_click_url: false,
|
||||||
|
click_url: "".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a slice of wire messages + applications into Slint items.
|
||||||
|
pub fn gotify_messages_to_items(
|
||||||
|
msgs: &[GotifyMessage],
|
||||||
|
apps: &[GotifyApplication],
|
||||||
|
) -> Vec<MessageItem> {
|
||||||
|
let apps_by_id: std::collections::HashMap<i64, GotifyApplication> =
|
||||||
|
apps.iter().map(|a| (a.id, a.clone())).collect();
|
||||||
|
msgs.iter().map(|m| gotify_message_to_item(m, &apps_by_id)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map Gotify numeric priority to the Slint `Priority` enum. Gotify uses
|
||||||
|
/// 0..10 (0=min, 10=max). Slint's enum has 4 buckets (§7).
|
||||||
|
fn map_slint(p: i32) -> crate::Priority {
|
||||||
|
match p {
|
||||||
|
x if x <= 0 => crate::Priority::Low,
|
||||||
|
x if x <= 3 => crate::Priority::Normal,
|
||||||
|
x if x <= 7 => crate::Priority::High,
|
||||||
|
_ => crate::Priority::Max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to load real Gotify data and convert it to a Slint model.
|
||||||
|
/// Returns `None` on any error — caller falls back to mock data.
|
||||||
|
pub fn try_fetch_real_messages(
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Option<(ModelRc<MessageItem>, String)> {
|
||||||
|
use crate::gotify::GotifyApi;
|
||||||
|
|
||||||
|
let api = GotifyApi::new(crate::gotify::ServerConfig::new(
|
||||||
|
base_url.to_string(),
|
||||||
|
token.to_string(),
|
||||||
|
))
|
||||||
|
.ok()?;
|
||||||
|
let health = api.health().ok()?;
|
||||||
|
if health.health != "green" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let apps = api.applications().ok()?;
|
||||||
|
let msgs = api.messages(50).ok()?;
|
||||||
|
let items = gotify_messages_to_items(&msgs, &apps);
|
||||||
|
Some((
|
||||||
|
ModelRc::from(std::rc::Rc::new(VecModel::from(items))),
|
||||||
|
format!("{} · {} messages", api.base_url(), msgs.len()),
|
||||||
|
))
|
||||||
|
}
|
||||||
+52
-27
@@ -1,9 +1,8 @@
|
|||||||
// Main window + sidebar (AGENTS.md §6, §7).
|
// Main window + sidebar (AGENTS.md §6, §7).
|
||||||
//
|
//
|
||||||
// Phase 2 C3 wires the Settings page, About dialog, and window-state
|
// Phase 3 C2 wires the live connection status (dot + status text under the
|
||||||
// persistence. The Appearance section in `settings-page.slint` is the only
|
// title bar) and accepts a `test-connection-requested` callback fired by
|
||||||
// settings UI that drives real state today — every other control is a
|
// the Settings → Connection section.
|
||||||
// shell that Phase 5 hooks to `AppConfig`.
|
|
||||||
|
|
||||||
import { AppearanceMode } from "./theme.slint";
|
import { AppearanceMode } from "./theme.slint";
|
||||||
import { NavItem, SectionLabel } from "./components.slint";
|
import { NavItem, SectionLabel } from "./components.slint";
|
||||||
@@ -26,11 +25,9 @@ export component MainWindow inherits Window {
|
|||||||
min-width: 760px;
|
min-width: 760px;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
|
|
||||||
// Theme switch — Rust-controllable via `ui.set_theme(...)`.
|
|
||||||
in-out property <AppearanceMode> theme: AppearanceMode.Dark;
|
in-out property <AppearanceMode> theme: AppearanceMode.Dark;
|
||||||
out property <bool> is-dark: theme != AppearanceMode.Light;
|
out property <bool> is-dark: theme != AppearanceMode.Light;
|
||||||
|
|
||||||
// Palette — all derived from `is-dark` (AGENTS.md §52).
|
|
||||||
out property <color> bg: is-dark ? #1f2024 : #f5f6f8;
|
out property <color> bg: is-dark ? #1f2024 : #f5f6f8;
|
||||||
out property <color> surface: is-dark ? #28292d : #ffffff;
|
out property <color> surface: is-dark ? #28292d : #ffffff;
|
||||||
out property <color> surface-elev: is-dark ? #2f3035 : #fafbfc;
|
out property <color> surface-elev: is-dark ? #2f3035 : #fafbfc;
|
||||||
@@ -58,13 +55,13 @@ export component MainWindow inherits Window {
|
|||||||
callback message-card-clicked(int);
|
callback message-card-clicked(int);
|
||||||
|
|
||||||
// ── Phase 2 C3: Settings / About / window state ──────────────
|
// ── 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 <int> appearance-index: 2;
|
in-out property <int> appearance-index: 2;
|
||||||
|
|
||||||
// Show About dialog (one-shot — Rust closes it after handling).
|
|
||||||
callback about-clicked();
|
callback about-clicked();
|
||||||
callback close-requested-save-state();
|
|
||||||
|
// ── Phase 3 C2: live connection status ───────────────────────
|
||||||
|
in-out property <bool> is-connected: false;
|
||||||
|
in-out property <string> status-text: "Disconnected";
|
||||||
|
callback test-connection-requested();
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
// §7 Sidebar width: 220-240px.
|
// §7 Sidebar width: 220-240px.
|
||||||
@@ -83,12 +80,32 @@ export component MainWindow inherits Window {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: "Windows Client · Phase 2";
|
text: "Windows Client · Phase 3";
|
||||||
color: text-muted;
|
color: text-muted;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 16px; }
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
|
// Live status row (Phase 3 C2): dot + text.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 6px;
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: is-connected ? success : text-muted;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: is-connected ? "Connected" : "Disconnected";
|
||||||
|
color: is-connected ? success : text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
SectionLabel { label: "MESSAGES"; text-color: text-muted; }
|
SectionLabel { label: "MESSAGES"; text-color: text-muted; }
|
||||||
NavItem {
|
NavItem {
|
||||||
@@ -113,19 +130,19 @@ export component MainWindow inherits Window {
|
|||||||
label: "High";
|
label: "High";
|
||||||
selected: false;
|
selected: false;
|
||||||
has-accent: false;
|
has-accent: false;
|
||||||
clicked => { /* TODO Phase 2 C2 */ }
|
clicked => { /* TODO Phase 3 C2: priority filter */ }
|
||||||
}
|
}
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Normal";
|
label: "Normal";
|
||||||
selected: false;
|
selected: false;
|
||||||
has-accent: false;
|
has-accent: false;
|
||||||
clicked => { /* TODO Phase 2 C2 */ }
|
clicked => { /* TODO Phase 3 C2: priority filter */ }
|
||||||
}
|
}
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Low";
|
label: "Low";
|
||||||
selected: false;
|
selected: false;
|
||||||
has-accent: false;
|
has-accent: false;
|
||||||
clicked => { /* TODO Phase 2 C2 */ }
|
clicked => { /* TODO Phase 3 C2: priority filter */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
@@ -151,9 +168,10 @@ export component MainWindow inherits Window {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
background: bg;
|
background: bg;
|
||||||
|
|
||||||
if root.selection == SidebarSelection.All: VerticalLayout {
|
VerticalLayout {
|
||||||
|
// Title bar with status text under it.
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
spacing: 12px;
|
spacing: 4px;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: messages.length == 0 ? "Messages" : "Messages · " + messages.length;
|
text: messages.length == 0 ? "Messages" : "Messages · " + messages.length;
|
||||||
@@ -161,11 +179,18 @@ export component MainWindow inherits Window {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
Text {
|
||||||
|
text: status-text;
|
||||||
|
color: text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
if messages.length > 0: MessageList {
|
if messages.length > 0: MessageList {
|
||||||
messages: messages;
|
messages: messages;
|
||||||
selected-index: root.selected-message-index;
|
selected-index: root.selected-message-index;
|
||||||
height: parent.height - 48px;
|
vertical-stretch: 1;
|
||||||
message-clicked(idx) => {
|
message-clicked(idx) => {
|
||||||
root.selected-message-index = idx;
|
root.selected-message-index = idx;
|
||||||
root.message-card-clicked(idx);
|
root.message-card-clicked(idx);
|
||||||
@@ -173,6 +198,7 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if messages.length == 0: EmptyState {
|
if messages.length == 0: EmptyState {
|
||||||
|
vertical-stretch: 1;
|
||||||
test-notification => {
|
test-notification => {
|
||||||
root.test-notification-clicked();
|
root.test-notification-clicked();
|
||||||
}
|
}
|
||||||
@@ -187,11 +213,14 @@ export component MainWindow inherits Window {
|
|||||||
idx == 1 ? AppearanceMode.Light :
|
idx == 1 ? AppearanceMode.Light :
|
||||||
AppearanceMode.Dark;
|
AppearanceMode.Dark;
|
||||||
}
|
}
|
||||||
|
test-connection-requested => {
|
||||||
|
root.test-connection-requested();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// About is rendered via a Dialog overlay — not in the right pane.
|
// About entry stays as a simple right-pane marker; the
|
||||||
// A separate About row keeps the layout stable; the dialog is
|
// modal AboutDialog overlay is wired through AboutDialog in
|
||||||
// shown via `about-clicked` callback to Rust.
|
// this component (visibility toggled via `about-clicked`).
|
||||||
if root.selection == SidebarSelection.About: Rectangle {
|
if root.selection == SidebarSelection.About: Rectangle {
|
||||||
background: bg;
|
background: bg;
|
||||||
Text {
|
Text {
|
||||||
@@ -205,9 +234,5 @@ export component MainWindow inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal About dialog — opened when the user clicks About in the sidebar.
|
AboutDialog {}
|
||||||
AboutDialog {
|
|
||||||
// visible-by-default is false. Toggled via Rust by listening to
|
|
||||||
// `about-clicked`. C3 wires the bare open/close plumbing.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,10 @@ export component SettingsPage inherits Rectangle {
|
|||||||
// Advanced (collapsed by default per §10)
|
// Advanced (collapsed by default per §10)
|
||||||
in-out property <bool> advanced-expanded: false;
|
in-out property <bool> advanced-expanded: false;
|
||||||
|
|
||||||
|
// Connection (Phase 3 C2)
|
||||||
|
in-out property <bool> test-in-progress: false;
|
||||||
|
callback test-connection-requested();
|
||||||
|
|
||||||
// Tokens
|
// Tokens
|
||||||
in property <color> divider-color: #3a3b40;
|
in property <color> divider-color: #3a3b40;
|
||||||
in property <color> hint-color: #6a6a6a;
|
in property <color> hint-color: #6a6a6a;
|
||||||
@@ -151,6 +155,29 @@ export component SettingsPage inherits Rectangle {
|
|||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
spacing: 8px;
|
spacing: 8px;
|
||||||
SectionLabel { label: "CONNECTION"; text-color: hint-color; }
|
SectionLabel { label: "CONNECTION"; text-color: hint-color; }
|
||||||
|
|
||||||
|
// "Test Connection" button — Phase 3 C2 wires this to the
|
||||||
|
// real `GotifyApi::health` round-trip.
|
||||||
|
Rectangle {
|
||||||
|
width: 160px;
|
||||||
|
height: 32px;
|
||||||
|
background: #2f3035;
|
||||||
|
border-radius: 6px;
|
||||||
|
Text {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: root.test-in-progress ? "Testing..." : "Test Connection";
|
||||||
|
color: #e6e6e6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
TouchArea {
|
||||||
|
enabled: !root.test-in-progress;
|
||||||
|
clicked => {
|
||||||
|
root.test-connection-requested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ServerCard {
|
ServerCard {
|
||||||
server-name: "Home";
|
server-name: "Home";
|
||||||
server-url: "https://gotify.example.com";
|
server-url: "https://gotify.example.com";
|
||||||
|
|||||||
Reference in New Issue
Block a user