// Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod config; mod io; mod runtime; #[cfg(target_os = "android")] mod android; #[cfg(target_arch = "wasm32")] mod wasm; use std::{ rc::Rc, sync::{Arc, Mutex}, }; use inkopslista_lib::{ButtonItem, Item, ItemData}; use reqwest::Url; use slint::{ComponentHandle, Model, ModelRc, ToSharedString, VecModel}; use crate::{config::Config, io::Io}; /// Exported slint types mod ui { slint::include_modules!(); } /// GET "/api/list" from the server async fn get_list(conf: &Mutex) -> Result, anyhow::Error> { let base = Url::parse(&conf.lock().unwrap().address)?; let url = base.join("/api/list")?; let list = reqwest::get(url).await?.json::>().await?; Ok(list) } async fn get_favorites(conf: &Mutex) -> Result, anyhow::Error> { let base = Url::parse(&conf.lock().unwrap().address)?; let url = base.join("/api/favorites")?; let favs = reqwest::get(url).await?.json::>().await?; Ok(favs) } /// Add or update an item in the list async fn put_list(item: &str, data: &ItemData, conf: &Mutex) -> Result<(), anyhow::Error> { let client = reqwest::Client::new(); let base = Url::parse(&conf.lock().unwrap().address)?.join("/api/list/")?; let url = base.join(item)?; let _response = client .put(url) .json(data) .send() .await? .error_for_status()?; Ok(()) } async fn delete_item(item: &str, conf: &Mutex) -> Result<(), anyhow::Error> { let client = reqwest::Client::new(); let base = Url::parse(&conf.lock().unwrap().address)?.join("/api/list/")?; let url = base.join(item)?; let _response = client.delete(url).send().await?.error_for_status()?; Ok(()) } async fn put_category( itemname: &str, category: &str, conf: &Mutex, ) -> Result<(), anyhow::Error> { let client = reqwest::Client::new(); let url: Url = format!( "{address}/api/favorites/{itemname}/category", address = conf.lock().unwrap().address ) .parse()?; let _response = client .put(url) .body(category.to_string()) .send() .await? .error_for_status()?; Ok(()) } /// Convert shopping list to slint types fn update_slint_list(new_list: Vec, list: &VecModel) { list.clear(); new_list .iter() .map(|item| ui::SItem { name: item.name.to_shared_string(), checked: item.data.checked, amount: item.data.amount as i32, }) .for_each(|item| list.push(item)); } fn update_slint_favs(new_favs: Vec, favs: &VecModel) { favs.clear(); new_favs .into_iter() .map(|item| ui::SButtonItem { name: item.name.into(), category: item.category.unwrap_or_default().to_shared_string(), }) .for_each(|item| favs.push(item)); } fn refresh_list(conf: &Arc>, app_weak: &slint::Weak, io: &Io) { let conf = conf.clone(); let app_weak = app_weak.clone(); io.spawn(async move || { let new_list = get_list(&conf).await?; let _ = app_weak.upgrade_in_event_loop(|app| { let list: ModelRc = app.global::().get_list(); let list: &VecModel = list.as_any().downcast_ref().expect("list is a VecModel"); update_slint_list(new_list, list); }); let new_favs = get_favorites(&conf).await?; let _ = app_weak.upgrade_in_event_loop(|app| { let favs: ModelRc = app.global::().get_favorites(); let favs: &VecModel = favs.as_any().downcast_ref().expect("favs is a vec model"); update_slint_favs(new_favs, favs); }); Ok(()) }); } pub fn run() -> Result<(), anyhow::Error> { let app = ui::App::new()?; let io_ = Io::new(&app); let state = app.global::(); let list_shared = Rc::new(VecModel::default()); let fav_shared = Rc::new(VecModel::default()); state.set_list(ModelRc::new(list_shared.clone())); state.set_favorites(ModelRc::new(fav_shared.clone())); #[cfg(target_os = "linux")] let conf = runtime::RT .block_on(config::get_config()) .inspect_err(|err| eprintln!("{err:?}")) .unwrap_or_default(); #[cfg(target_os = "android")] let conf = Config::default(); #[cfg(target_arch = "wasm32")] let mut conf = Config::default(); #[cfg(target_arch = "wasm32")] web_sys::window() .and_then(|window| window.document()) .and_then(|document| document.url().ok()) .map(|url| conf.address = url); state.set_server_address(conf.address.to_shared_string()); let conf_shared = Arc::new(Mutex::new(conf)); let conf = conf_shared.clone(); let app_weak = app.as_weak(); // load shopping list from server on start refresh_list(&conf, &app_weak, &io_); let io = io_.clone(); state.on_refresh_list(move || { refresh_list(&conf, &app_weak, &io); }); let conf = conf_shared.clone(); let list = list_shared.clone(); let io = io_.clone(); state.on_add_to_list(move |item| { if let Some(index) = list.iter().position(|item2| item2.name == item) { let mut item2 = list.row_data(index).unwrap(); item2.amount += 1; let item3 = item2.clone(); list.set_row_data(index, item2); let conf = conf.clone(); io.spawn(async move || { put_list( &item, &ItemData { checked: item3.checked, amount: item3.amount as i64, }, &conf, ) .await }); return; } list.push(ui::SItem { checked: false, name: item.clone(), amount: 1, }); let conf = conf.clone(); io.spawn(async move || { // TODO: add number to increment put_list( &item, &ItemData { checked: false, amount: 1, }, &conf, ) .await }); }); let conf = conf_shared.clone(); let list = list_shared.clone(); let io = io_.clone(); state.on_check_item(move |item| { let positem = list .iter() .enumerate() .find(|(_, item2)| item2.name == item); let Some((pos, mut item)) = positem else { return; }; item.checked = !item.checked; list.set_row_data(pos, item.clone()); let conf = conf.clone(); io.spawn(async move || { put_list( &item.name, &ItemData { checked: item.checked, amount: 1, }, &conf, ) .await }); }); let conf = conf_shared.clone(); let list = list_shared.clone(); let io = io_.clone(); state.on_delete_checked(move || { let poss: Vec<_> = list .iter() .enumerate() .filter(|(_, item)| item.checked) .map(|(pos, item)| (pos, item.name)) .collect(); for &(pos, _) in poss.iter().rev() { list.remove(pos); } let conf = conf.clone(); io.spawn(async move || { let mut result = Ok(()); for (_, name) in poss { let r = delete_item(&name, &conf).await; if r.is_err() { result = r; } } result }); }); let conf = conf_shared.clone(); let list = list_shared.clone(); let io = io_.clone(); state.on_inc_amount(move |item| { if let Some(index) = list.iter().position(|item2| item2.name == item) { let mut item2 = list.row_data(index).unwrap(); item2.amount += 1; let item3 = item2.clone(); list.set_row_data(index, item2); let conf = conf.clone(); io.spawn(async move || { put_list( &item, &ItemData { checked: item3.checked, amount: item3.amount as i64, }, &conf, ) .await }); } }); let conf = conf_shared.clone(); let list = list_shared.clone(); let io = io_.clone(); state.on_dec_amount(move |item| { if let Some(index) = list.iter().position(|item2| item2.name == item) { let mut item2 = list.row_data(index).unwrap(); item2.amount -= 1; let item3 = item2.clone(); list.set_row_data(index, item2); let conf = conf.clone(); if item3.amount < 1 { list.remove(index); io.spawn(async move || delete_item(&item3.name, &conf).await); } else { io.spawn(async move || { // TODO: add number to increment put_list( &item, &ItemData { checked: item3.checked, amount: item3.amount as i64, }, &conf, ) .await }); } } }); let conf = conf_shared.clone(); let io = io_.clone(); state.on_set_cat(move |itemname, cat| { let conf = conf.clone(); io.spawn(async move || put_category(&itemname, &cat, &conf).await); return; }); state.on_set_server_address(move |address| { conf_shared.lock().unwrap().address = address.to_string(); #[cfg(target_os = "linux")] let _ = config::save_config(address.to_string()); }); app.run()?; Ok(()) }