diff --git a/gui/images/arrow-down-up.svg b/gui/images/arrow-down-up.svg new file mode 100644 index 0000000..2996226 --- /dev/null +++ b/gui/images/arrow-down-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/gui/src/io.rs b/gui/src/io.rs new file mode 100644 index 0000000..363235c --- /dev/null +++ b/gui/src/io.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use slint::ComponentHandle; +use tokio::sync::mpsc; + +use crate::{runtime::spawn, ui}; + +/// A thing for spawning I/O-tasks and updating the GUI `ConnectionStatus` with the result. +#[derive(Clone)] +pub struct Io { + state: Arc, +} + +struct State { + status: mpsc::Sender, +} + +impl Io { + pub fn new(app: &ui::App) -> Self { + let (tx, mut rx) = mpsc::channel(10); + let app_weak = app.as_weak(); + + spawn(async move { + loop { + let Some(status) = rx.recv().await else { + return; + }; + + let _ = app_weak.upgrade_in_event_loop(move |app| { + app.global::().set_connection_status(status); + }); + } + }); + + Io { + state: Arc::new(State { status: tx }), + } + } + + /// Spawn a fallible async function. + /// + /// Update the `ConnectionStatus` in the GUI depending on whether the result is an error. + pub fn spawn(&self, f: impl FnOnce() -> Fut + Send + 'static) + where + Fut: Future>, + Fut: Send + 'static, + { + let io = self.clone(); + spawn(async move { + let _ = io.state.status.send(ui::ConnectionStatus::Syncing).await; + let result = f().await; + let was_error = result.is_err(); + if let Err(e) = result { + eprintln!("{e:?}"); + } + let _ = io + .state + .status + .send(if was_error { + ui::ConnectionStatus::Error + } else { + ui::ConnectionStatus::Idle + }) + .await; + }); + } +} diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 29fc51d..fc1d78d 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod config; +mod io; mod runtime; #[cfg(target_os = "android")] @@ -17,11 +18,14 @@ use std::{ use inkopslista_lib::{Item, ItemData}; use reqwest::Url; -use slint::{Model, ModelRc, ToSharedString, VecModel}; +use slint::{ComponentHandle, Model, ModelRc, ToSharedString, VecModel}; -use crate::{config::Config, runtime::spawn}; +use crate::{config::Config, io::Io}; -slint::include_modules!(); +/// Exported slint types +mod ui { + slint::include_modules!(); +} /// GET "/api/list" from the server async fn get_list(conf: &Mutex) -> Result, anyhow::Error> { @@ -54,11 +58,11 @@ async fn delete_item(item: &str, conf: &Mutex) -> Result<(), anyhow::Err } /// Convert shopping list to slint types -fn update_slint_list(new_list: Vec, list: &VecModel) { +fn update_slint_list(new_list: Vec, list: &VecModel) { list.clear(); new_list .iter() - .map(|item| SItem { + .map(|item| ui::SItem { name: item.name.to_shared_string(), checked: item.data.checked, amount: item.data.amount as i32, @@ -66,28 +70,25 @@ fn update_slint_list(new_list: Vec, list: &VecModel) { .for_each(|item| list.push(item)); } -fn refresh_list(conf: &Arc>, app_weak: &slint::Weak) { +fn refresh_list(conf: &Arc>, app_weak: &slint::Weak, io: &Io) { let conf = conf.clone(); let app_weak = app_weak.clone(); - spawn(async move { - let new_list = match get_list(&conf).await { - Ok(new_list) => new_list, - Err(e) => { - eprintln!("{e:?}"); - return; - } - }; + 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"); + 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); }); + Ok(()) }); } pub fn run() -> Result<(), anyhow::Error> { - let app = App::new()?; - let state = app.global::(); + let app = ui::App::new()?; + let io_ = Io::new(&app); + let state = app.global::(); let list_shared = Rc::new(VecModel::default()); state.set_list(ModelRc::new(list_shared.clone())); @@ -115,13 +116,15 @@ pub fn run() -> Result<(), anyhow::Error> { let conf = conf_shared.clone(); let app_weak = app.as_weak(); // load shopping list from server on start - refresh_list(&conf, &app_weak); + refresh_list(&conf, &app_weak, &io_); + let io = io_.clone(); state.on_refresh_list(move || { - refresh_list(&conf, &app_weak); + 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(); @@ -129,8 +132,8 @@ pub fn run() -> Result<(), anyhow::Error> { let item3 = item2.clone(); list.set_row_data(index, item2); let conf = conf.clone(); - spawn(async move { - let res = put_list( + io.spawn(async move || { + put_list( &item, &ItemData { checked: item3.checked, @@ -138,22 +141,19 @@ pub fn run() -> Result<(), anyhow::Error> { }, &conf, ) - .await; - if let Err(err) = res { - eprintln!("{err:?}"); - } + .await }); return; } - list.push(SItem { + list.push(ui::SItem { checked: false, name: item.clone(), amount: 1, }); let conf = conf.clone(); - spawn(async move { + io.spawn(async move || { // TODO: add number to increment - let res = put_list( + put_list( &item, &ItemData { checked: false, @@ -161,30 +161,27 @@ pub fn run() -> Result<(), anyhow::Error> { }, &conf, ) - .await; - if let Err(err) = res { - eprintln!("{err:?}"); - } + .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 (pos, mut item) = match positem { - Some(positem) => positem, - None => return, + let Some((pos, mut item)) = positem else { + return; }; item.checked = !item.checked; list.set_row_data(pos, item.clone()); let conf = conf.clone(); - spawn(async move { - let res = put_list( + io.spawn(async move || { + put_list( &item.name, &ItemData { checked: item.checked, @@ -192,15 +189,13 @@ pub fn run() -> Result<(), anyhow::Error> { }, &conf, ) - .await; - if let Err(err) = res { - eprintln!("{err:?}"); - } + .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() @@ -214,18 +209,21 @@ pub fn run() -> Result<(), anyhow::Error> { let conf = conf.clone(); - spawn(async move { + io.spawn(async move || { + let mut result = Ok(()); for (_, name) in poss { - let res = delete_item(&name, &conf).await; - if let Err(err) = res { - eprintln!("{err:?}"); + 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(); @@ -233,8 +231,8 @@ pub fn run() -> Result<(), anyhow::Error> { let item3 = item2.clone(); list.set_row_data(index, item2); let conf = conf.clone(); - spawn(async move { - let res = put_list( + io.spawn(async move || { + put_list( &item, &ItemData { checked: item3.checked, @@ -242,16 +240,14 @@ pub fn run() -> Result<(), anyhow::Error> { }, &conf, ) - .await; - if let Err(err) = res { - eprintln!("{err:?}"); - } + .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(); @@ -261,16 +257,11 @@ pub fn run() -> Result<(), anyhow::Error> { let conf = conf.clone(); if item3.amount < 1 { list.remove(index); - spawn(async move { - let res = delete_item(&item3.name, &conf).await; - if let Err(err) = res { - eprintln!("{err:?}"); - } - }); + io.spawn(async move || delete_item(&item3.name, &conf).await); } else { - spawn(async move { + io.spawn(async move || { // TODO: add number to increment - let res = put_list( + put_list( &item, &ItemData { checked: item3.checked, @@ -278,10 +269,7 @@ pub fn run() -> Result<(), anyhow::Error> { }, &conf, ) - .await; - if let Err(err) = res { - eprintln!("{err:?}"); - } + .await }); } } diff --git a/gui/src/net.rs b/gui/src/net.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/gui/src/net.rs @@ -0,0 +1 @@ + diff --git a/gui/ui/app.slint b/gui/ui/app.slint index 0c02324..c31b297 100644 --- a/gui/ui/app.slint +++ b/gui/ui/app.slint @@ -7,11 +7,11 @@ import { CheckBox, ScrollView, GridBox, - Palette, + Palette, Spinner, SpinBox, } from "std-widgets.slint"; import { SettingsView } from "settings.slint"; -import { State, SItem } from "state.slint"; -export { State, SItem } +import { ConnectionStatus, State, SItem } from "state.slint"; +export { ConnectionStatus, State, SItem } component GoodListItem inherits Rectangle { @@ -201,7 +201,30 @@ component GoodButtons inherits ScrollView { } } +component ConnStatusIndicator inherits Rectangle { + width: 18px; + Image { + states [ + idle when State.connection-status == ConnectionStatus.Idle: { + opacity: 0%; + } + syncing when State.connection-status == ConnectionStatus.Syncing: { + colorize: green; + opacity: 75% + 25% * sin(360deg * animation-tick() / 1s); + } + error when State.connection-status == ConnectionStatus.Error: { + colorize: red; + opacity: 100%; + } + ] + source: @image-url("../images/arrow-down-up.svg"); + width: 24px; + height: self.width; + } +} + component RefreshButton inherits HorizontalLayout { + spacing: 8px; alignment: center; Button { text: "Refresh"; @@ -210,6 +233,7 @@ component RefreshButton inherits HorizontalLayout { State.refresh-list(); } } + ConnStatusIndicator { } } component AddView inherits VerticalLayout { @@ -218,7 +242,7 @@ component AddView inherits VerticalLayout { padding: 8px; spacing: 8px; - RefreshButton { } + RefreshButton {} goods := GoodsListAdd { items <=> list; diff --git a/gui/ui/state.slint b/gui/ui/state.slint index 082ae9d..14d4f29 100644 --- a/gui/ui/state.slint +++ b/gui/ui/state.slint @@ -4,6 +4,12 @@ export struct SItem { amount: int, } +export enum ConnectionStatus { + Idle, + Syncing, + Error, +} + export global State { in-out property <[SItem]> list: [ { name: "Test 1", checked: true }, @@ -21,4 +27,5 @@ export global State { set-server-address(address) => { server-address = address; } + in-out property connection-status: ConnectionStatus.Syncing; }