sleep_panel.rs
1 use crate::api::DeviceStatusData; 2 use crate::services::DeviceService; 3 use dioxus::prelude::*; 4 use lucide_dioxus::Moon; 5 use ui::components::button::{Button, ButtonVariant}; 6 use ui::components::switch::Switch; 7 use ui::components::toast::use_toast; 8 use super::shared_ui::StatusBadge; 9 10 const PRESETS: &[(u64, &str)] = &[ 11 (60, "1m"), 12 (300, "5m"), 13 (900, "15m"), 14 (3600, "1h"), 15 ]; 16 17 #[component] 18 pub fn SleepPanel( 19 device_url: Signal<String>, 20 status: Signal<Option<DeviceStatusData>>, 21 ) -> Element { 22 let toasts = use_toast(); 23 let mut enabled = use_signal(|| false); 24 let mut duration = use_signal(|| 300u64); 25 let mut custom_active = use_signal(|| false); 26 let mut custom_h = use_signal(String::new); 27 let mut custom_m = use_signal(String::new); 28 let mut custom_s = use_signal(String::new); 29 let mut dirty = use_signal(|| false); 30 let mut saving = use_signal(|| false); 31 32 let mut sync_hms_from_duration = move |secs: u64| { 33 custom_h.set((secs / 3600).to_string()); 34 custom_m.set(((secs % 3600) / 60).to_string()); 35 custom_s.set((secs % 60).to_string()); 36 }; 37 38 let mut recompute_duration = move || { 39 let h = custom_h.read().trim().parse::<u64>().unwrap_or(0); 40 let m = custom_m.read().trim().parse::<u64>().unwrap_or(0); 41 let s = custom_s.read().trim().parse::<u64>().unwrap_or(0); 42 duration.set(h * 3600 + m * 60 + s); 43 }; 44 45 use_effect(move || { 46 let snapshot = status.read().clone(); 47 if *dirty.read() { 48 return; 49 } 50 51 if let Some(snapshot) = snapshot { 52 enabled.set(snapshot.sleep.enabled); 53 let secs = snapshot.sleep.default_duration_seconds; 54 duration.set(secs); 55 if PRESETS.iter().any(|(s, _)| *s == secs) { 56 custom_active.set(false); 57 } else { 58 custom_active.set(true); 59 sync_hms_from_duration(secs); 60 } 61 } 62 }); 63 64 let snapshot = status.read().clone(); 65 let is_custom = *custom_active.read(); 66 67 rsx! { 68 section { id: "sleep-panel", class: "panel-shell-strong p-4", 69 div { class: "flex items-center justify-between gap-3", 70 div { class: "flex items-center gap-3", 71 h2 { class: "text-xl font-semibold", "Deep Sleep" } 72 if let Some(snapshot) = snapshot.as_ref() { 73 StatusBadge { 74 icon: rsx! { span { class: "block h-2 w-2 rounded-full bg-amber-400" } }, 75 value: snapshot.sleep.wake_cause.clone() 76 } 77 } 78 } 79 div { class: "flex items-center gap-3", 80 div { class: "flex items-center rounded-lg overflow-hidden border border-border", 81 for &(secs, label) in PRESETS { 82 { 83 let is_active = !is_custom && *duration.read() == secs; 84 rsx! { 85 button { 86 key: "{secs}", 87 class: if is_active { 88 "px-2.5 py-1.5 text-xs font-mono bg-primary text-primary-foreground" 89 } else { 90 "px-2.5 py-1.5 text-xs font-mono bg-background text-foreground/70 hover:bg-muted/30" 91 }, 92 onclick: move |_| { 93 duration.set(secs); 94 custom_active.set(false); 95 dirty.set(true); 96 }, 97 "{label}" 98 } 99 } 100 } 101 } 102 button { 103 class: if is_custom { 104 "px-2.5 py-1.5 text-xs font-mono bg-primary text-primary-foreground" 105 } else { 106 "px-2.5 py-1.5 text-xs font-mono bg-background text-foreground/70 hover:bg-muted/30" 107 }, 108 onclick: move |_| { 109 custom_active.set(true); 110 dirty.set(true); 111 sync_hms_from_duration(*duration.read()); 112 }, 113 "custom" 114 } 115 } 116 if is_custom { 117 div { class: "flex items-center", 118 input { 119 r#type: "number", 120 class: "gold-input w-7 px-0 py-1 text-xs font-mono text-center bg-background border border-border rounded-l", 121 aria_label: "Hours", 122 placeholder: "0", 123 value: custom_h.read().clone(), 124 oninput: move |e| { 125 custom_h.set(e.value()); 126 recompute_duration(); 127 dirty.set(true); 128 }, 129 } 130 span { class: "text-[10px] text-muted-foreground px-0.5", "h" } 131 input { 132 r#type: "number", 133 class: "gold-input w-7 px-0 py-1 text-xs font-mono text-center bg-background border border-border", 134 aria_label: "Minutes", 135 placeholder: "0", 136 value: custom_m.read().clone(), 137 oninput: move |e| { 138 custom_m.set(e.value()); 139 recompute_duration(); 140 dirty.set(true); 141 }, 142 } 143 span { class: "text-[10px] text-muted-foreground px-0.5", "m" } 144 input { 145 r#type: "number", 146 class: "gold-input w-7 px-0 py-1 text-xs font-mono text-center bg-background border border-border rounded-r", 147 aria_label: "Seconds", 148 placeholder: "0", 149 value: custom_s.read().clone(), 150 oninput: move |e| { 151 custom_s.set(e.value()); 152 recompute_duration(); 153 dirty.set(true); 154 }, 155 } 156 span { class: "text-[10px] text-muted-foreground pl-0.5", "s" } 157 } 158 } 159 Switch { 160 checked: enabled, 161 on_checked_change: move |value: bool| { 162 let duration_seconds = *duration.read(); 163 if duration_seconds == 0 { 164 toasts.error("Sleep duration must be greater than 0".to_string(), None); 165 return; 166 } 167 let url = device_url.read().clone(); 168 enabled.set(value); 169 dirty.set(true); 170 saving.set(true); 171 spawn(async move { 172 match DeviceService::update_sleep_config(&url, value, duration_seconds).await { 173 Ok(response) if response.ok => { 174 if let Ok(envelope) = DeviceService::get_status(&url).await { 175 status.set(Some(envelope.data)); 176 } 177 dirty.set(false); 178 toasts.success("Deep sleep configuration saved".to_string(), None); 179 } 180 Ok(_) => toasts.error("Sleep config update failed".to_string(), None), 181 Err(error) => toasts.error(format!("Sleep config update failed: {error}"), None), 182 } 183 saving.set(false); 184 }); 185 } 186 } 187 Button { 188 class: "px-3 py-1.5 rounded-lg text-foreground flex items-center gap-2 transition-colors hover:bg-muted/70 text-xs".to_string(), 189 variant: ButtonVariant::Outline, 190 on_click: move |_| { 191 let dur = *duration.read(); 192 if dur == 0 { 193 toasts.error("Set a duration first".to_string(), None); 194 return; 195 } 196 let url = device_url.read().clone(); 197 saving.set(true); 198 spawn(async move { 199 match DeviceService::trigger_sleep(&url).await { 200 Ok(response) if response.ok => { 201 toasts.success("Device entering deep sleep".to_string(), None); 202 } 203 Ok(_) => toasts.error("Sleep trigger failed".to_string(), None), 204 Err(error) => toasts.error(format!("Sleep trigger failed: {error}"), None), 205 } 206 saving.set(false); 207 }); 208 }, 209 Moon { class: "w-3.5 h-3.5" } 210 "Sleep" 211 } 212 } 213 } 214 } 215 } 216 }