settings.rs (17488B)
1 use std::{cell::RefCell, rc::Rc, sync::Arc}; 2 3 use fjordgard_weather::{MeteoClient, model::Location}; 4 use iced::{ 5 Background, Border, Color, Element, Length, Task, Theme, 6 widget::{button, column, combo_box, container, row, scrollable, text, text_input, tooltip}, 7 }; 8 use log::error; 9 #[cfg(not(target_arch = "wasm32"))] 10 use rfd::{AsyncFileDialog, FileHandle}; 11 use strum::VariantArray; 12 13 use crate::config::{self, BackgroundMode, Config}; 14 15 #[derive(Debug, Clone, PartialEq, strum::Display, strum::VariantArray)] 16 pub enum WeatherLocation { 17 Disabled, 18 #[strum(to_string = "Location name")] 19 LocationName, 20 Coordinates, 21 } 22 23 #[derive(Debug, Clone)] 24 pub struct LocationRow { 25 name: String, 26 latitude: f64, 27 longitude: f64, 28 } 29 30 pub struct Settings { 31 config: Rc<RefCell<Config>>, 32 meteo: Arc<MeteoClient>, 33 backgrounds: combo_box::State<BackgroundMode>, 34 locations: combo_box::State<WeatherLocation>, 35 #[cfg(not(target_arch = "wasm32"))] 36 file_selector_open: bool, 37 38 time_format: String, 39 background_mode: BackgroundMode, 40 background: String, 41 unsplash_key: String, 42 43 location: WeatherLocation, 44 name: String, 45 latitude: String, 46 longitude: String, 47 48 location_results: Vec<LocationRow>, 49 location_fetch_error: Option<String>, 50 } 51 52 #[derive(Debug, Clone)] 53 pub enum Message { 54 TimeFormat(String), 55 BackgroundMode(BackgroundMode), 56 Background(String), 57 UnsplashKey(String), 58 Location(WeatherLocation), 59 Name(String), 60 NameSubmitted, 61 Geocode(Result<Vec<Location>, String>), 62 LocationSelected(LocationRow), 63 Latitude(String), 64 Longitude(String), 65 #[cfg(not(target_arch = "wasm32"))] 66 FileSelector, 67 #[cfg(not(target_arch = "wasm32"))] 68 FileSelected(Option<FileHandle>), 69 Save, 70 CloseSettings, 71 72 Committed, 73 Saved(Result<(), String>), 74 75 #[cfg(target_arch = "wasm32")] 76 ToBackground(crate::background::Message), 77 } 78 79 impl Settings { 80 pub fn new(config: Rc<RefCell<Config>>, meteo: Arc<MeteoClient>) -> Self { 81 let original_config = config.borrow().clone(); 82 let location = original_config.location; 83 84 let latitude = location 85 .as_ref() 86 .map(|l| l.latitude.to_string()) 87 .unwrap_or_default(); 88 let longitude = location 89 .as_ref() 90 .map(|l| l.longitude.to_string()) 91 .unwrap_or_default(); 92 let name = location 93 .as_ref() 94 .and_then(|l| l.name.clone()) 95 .unwrap_or_default(); 96 let location = location 97 .as_ref() 98 .map(|l| { 99 l.name 100 .as_ref() 101 .map(|_| WeatherLocation::LocationName) 102 .unwrap_or(WeatherLocation::Coordinates) 103 }) 104 .unwrap_or(WeatherLocation::Disabled); 105 106 Self { 107 config, 108 meteo, 109 backgrounds: combo_box::State::new(BackgroundMode::VARIANTS.to_vec()), 110 locations: combo_box::State::new(WeatherLocation::VARIANTS.to_vec()), 111 #[cfg(not(target_arch = "wasm32"))] 112 file_selector_open: false, 113 114 time_format: original_config.time_format, 115 background_mode: original_config.background_mode, 116 background: original_config.background, 117 unsplash_key: original_config.unsplash_key.unwrap_or_default(), 118 119 location, 120 latitude, 121 longitude, 122 name, 123 124 location_results: vec![], 125 location_fetch_error: None, 126 } 127 } 128 129 pub fn update(&mut self, msg: Message) -> Task<Message> { 130 match msg { 131 Message::TimeFormat(format) => { 132 self.time_format = format; 133 Task::none() 134 } 135 Message::BackgroundMode(mode) => { 136 self.background = mode.default_background().to_string(); 137 self.background_mode = mode; 138 Task::none() 139 } 140 Message::Background(background) => { 141 self.background = background; 142 Task::none() 143 } 144 Message::UnsplashKey(key) => { 145 self.unsplash_key = key; 146 Task::none() 147 } 148 Message::Location(location) => { 149 self.location = location; 150 Task::none() 151 } 152 Message::Name(name) => { 153 self.name = name; 154 Task::none() 155 } 156 Message::NameSubmitted => { 157 self.location_fetch_error = None; 158 let meteo = self.meteo.clone(); 159 let name = self.name.clone(); 160 161 Task::future(async move { meteo.geocode(&name, None).await }) 162 .map(|r| Message::Geocode(r.map_err(|e| e.to_string()))) 163 } 164 Message::Geocode(locations) => { 165 match locations { 166 Err(e) => { 167 error!("failed to fetch geocode: {e}"); 168 self.location_fetch_error = Some(e); 169 } 170 Ok(res) => { 171 self.location_results = res 172 .iter() 173 .map(|l| { 174 let level1 = if let Some(admin1) = &l.admin1 { 175 format!(", {admin1}") 176 } else { 177 String::new() 178 }; 179 180 LocationRow { 181 name: format!("{}{level1}, {}", l.name, l.country), 182 latitude: l.latitude, 183 longitude: l.longitude, 184 } 185 }) 186 .collect() 187 } 188 }; 189 190 Task::none() 191 } 192 Message::LocationSelected(loc) => { 193 self.name = loc.name; 194 self.latitude = loc.latitude.to_string(); 195 self.longitude = loc.longitude.to_string(); 196 197 Task::none() 198 } 199 Message::Latitude(latitude) => { 200 self.latitude = latitude; 201 Task::none() 202 } 203 Message::Longitude(longitude) => { 204 self.longitude = longitude; 205 Task::none() 206 } 207 #[cfg(not(target_arch = "wasm32"))] 208 Message::FileSelector => { 209 if self.file_selector_open { 210 return Task::none(); 211 } 212 213 self.file_selector_open = true; 214 215 let file_task = AsyncFileDialog::new() 216 .add_filter("image", &["png", "jpeg", "jpg"]) 217 .pick_file(); 218 219 Task::future(file_task).map(Message::FileSelected) 220 } 221 #[cfg(not(target_arch = "wasm32"))] 222 Message::FileSelected(file) => { 223 self.file_selector_open = false; 224 225 if let Some(file) = file { 226 self.background = file.path().to_string_lossy().to_string(); 227 } 228 229 Task::none() 230 } 231 Message::Save => { 232 let mut config = self.config.borrow_mut(); 233 234 config.time_format = self.time_format.clone(); 235 config.background_mode = self.background_mode; 236 config.background = self.background.clone(); 237 config.unsplash_key = if self.unsplash_key.is_empty() { 238 None 239 } else { 240 Some(self.unsplash_key.clone()) 241 }; 242 243 match self.location { 244 WeatherLocation::Disabled => config.location = None, 245 _ => { 246 config.location = Some(config::Location { 247 // this *should* be safe if we're at this point 248 longitude: self.longitude.parse().unwrap(), 249 latitude: self.latitude.parse().unwrap(), 250 name: if self.location == WeatherLocation::LocationName { 251 Some(self.name.clone()) 252 } else { 253 None 254 }, 255 }) 256 } 257 } 258 259 let cloned = config.clone(); 260 261 Task::batch([ 262 Task::done(Message::Committed), 263 Task::future(async move { cloned.save().await }) 264 .map(|r| Message::Saved(r.map_err(|e| e.to_string()))), 265 ]) 266 } 267 Message::Saved(res) => match res { 268 Err(e) => { 269 error!("failed to save config: {e}"); 270 Task::none() 271 } 272 Ok(()) => Task::none(), 273 }, 274 _ => Task::none(), 275 } 276 } 277 278 pub fn view(&self) -> Element<Message> { 279 let (latitude, longitude, name) = match self.location { 280 WeatherLocation::Disabled => (None, None, None), 281 WeatherLocation::LocationName => (None, None, Some(Message::Name)), 282 WeatherLocation::Coordinates => { 283 (Some(Message::Latitude), Some(Message::Longitude), None) 284 } 285 }; 286 287 let mut save_message = Some(Message::Save); 288 289 let color_style = if (self.background_mode == BackgroundMode::Solid 290 && Color::parse(&self.background).is_none()) 291 || (self.background_mode == BackgroundMode::Unsplash && self.background.is_empty()) 292 { 293 save_message = None; 294 text_input_error 295 } else { 296 text_input::default 297 }; 298 299 let unsplash_style = 300 if self.background_mode == BackgroundMode::Unsplash && self.unsplash_key.is_empty() { 301 save_message = None; 302 text_input_error 303 } else { 304 text_input::default 305 }; 306 307 let unsplash_key = if self.background_mode == BackgroundMode::Unsplash { 308 Some(Message::UnsplashKey) 309 } else { 310 None 311 }; 312 313 let latitude_style = if self.latitude.parse::<f64>().is_err() 314 && matches!( 315 self.location, 316 WeatherLocation::LocationName | WeatherLocation::Coordinates 317 ) { 318 save_message = None; 319 text_input_error 320 } else { 321 text_input::default 322 }; 323 324 let longitude_style = if self.longitude.parse::<f64>().is_err() 325 && matches!( 326 self.location, 327 WeatherLocation::LocationName | WeatherLocation::Coordinates 328 ) { 329 save_message = None; 330 text_input_error 331 } else { 332 text_input::default 333 }; 334 335 let mut background_mode_row = 336 row![text(self.background_mode.edit_text()).width(Length::FillPortion(1))]; 337 338 match self.background_mode { 339 #[cfg(not(target_arch = "wasm32"))] 340 BackgroundMode::Local => { 341 let text = if self.background.is_empty() { 342 save_message = None; 343 "Select file..." 344 } else { 345 &self.background 346 }; 347 348 background_mode_row = background_mode_row.push( 349 button(text) 350 .on_press(Message::FileSelector) 351 .width(Length::FillPortion(2)), 352 ); 353 } 354 _ => { 355 background_mode_row = background_mode_row.push( 356 text_input(self.background_mode.default_background(), &self.background) 357 .on_input(Message::Background) 358 .width(Length::FillPortion(2)) 359 .style(color_style), 360 ); 361 } 362 } 363 364 let mut results = column![]; 365 366 for res in self.location_results.iter() { 367 results = results.push( 368 button(text(format!( 369 "{} ({}, {})", 370 res.name, res.latitude, res.longitude 371 ))) 372 .style(button::text) 373 .on_press_with(|| Message::LocationSelected(res.clone())), 374 ) 375 } 376 377 let location_style = if self.location_fetch_error.is_some() { 378 save_message = None; 379 text_input_error 380 } else { 381 text_input::default 382 }; 383 384 let mut location_row: Element<Message> = row![ 385 text("Location").width(Length::FillPortion(1)), 386 text_input("", &self.name) 387 .width(Length::FillPortion(2)) 388 .on_input_maybe(name) 389 .on_submit(Message::NameSubmitted) 390 .style(location_style) 391 ] 392 .into(); 393 394 if let Some(err) = &self.location_fetch_error { 395 location_row = tooltip( 396 location_row, 397 container(err.as_ref()) 398 .padding(5) 399 .style(container::rounded_box), 400 tooltip::Position::Top, 401 ) 402 .into() 403 }; 404 405 scrollable( 406 container( 407 column![ 408 row![ 409 text("Time format").width(Length::FillPortion(1)), 410 text_input("", &self.time_format) 411 .width(Length::FillPortion(2)) 412 .on_input(Message::TimeFormat) 413 ], 414 row![ 415 text("Background mode").width(Length::FillPortion(1)), 416 combo_box( 417 &self.backgrounds, 418 "", 419 Some(&self.background_mode), 420 Message::BackgroundMode 421 ) 422 .width(Length::FillPortion(2)) 423 ], 424 background_mode_row, 425 row![ 426 text("Unsplash API Key").width(Length::FillPortion(1)), 427 text_input("", &self.unsplash_key) 428 .width(Length::FillPortion(2)) 429 .on_input_maybe(unsplash_key) 430 .style(unsplash_style) 431 ], 432 row![ 433 text("Weather Location").width(Length::FillPortion(1)), 434 combo_box(&self.locations, "", Some(&self.location), Message::Location) 435 .width(Length::FillPortion(2)) 436 ], 437 row![ 438 text("Latitude").width(Length::FillPortion(1)), 439 text_input("", &self.latitude) 440 .width(Length::FillPortion(2)) 441 .on_input_maybe(latitude) 442 .style(latitude_style) 443 ], 444 row![ 445 text("Longitude").width(Length::FillPortion(1)), 446 text_input("", &self.longitude) 447 .width(Length::FillPortion(2)) 448 .on_input_maybe(longitude) 449 .style(longitude_style) 450 ], 451 location_row, 452 scrollable(results) 453 .height(Length::Fixed( 454 64.0 * (self.location_results.len().clamp(0, 1) as f32) 455 )) 456 .width(Length::Fill), 457 row![ 458 button("Save").on_press_maybe(save_message), 459 button("Close").on_press(Message::CloseSettings), 460 ] 461 .spacing(5) 462 ] 463 .spacing(10), 464 ) 465 .padding(15), 466 ) 467 .into() 468 } 469 } 470 471 fn text_input_error(theme: &Theme, status: text_input::Status) -> text_input::Style { 472 let palette = theme.extended_palette(); 473 474 let active = text_input::Style { 475 background: Background::Color(palette.danger.weak.color), 476 border: Border { 477 radius: 2.0.into(), 478 width: 1.0, 479 color: palette.danger.strong.color, 480 }, 481 icon: palette.danger.weak.text, 482 placeholder: palette.danger.strong.color, 483 value: palette.danger.weak.text, 484 selection: palette.danger.strong.color, 485 }; 486 487 match status { 488 text_input::Status::Active => active, 489 text_input::Status::Hovered => text_input::Style { 490 border: Border { 491 color: palette.danger.base.text, 492 ..active.border 493 }, 494 ..active 495 }, 496 text_input::Status::Focused => text_input::Style { 497 border: Border { 498 color: palette.background.strong.color, 499 ..active.border 500 }, 501 ..active 502 }, 503 text_input::Status::Disabled => text_input::Style { 504 background: Background::Color(palette.danger.weak.color), 505 value: active.placeholder, 506 ..active 507 }, 508 } 509 }