diff --git a/Cargo.lock b/Cargo.lock index f1f2ffb..3aa632b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-trait" +version = "0.1.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "atoi" version = "2.0.0" @@ -420,7 +431,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -736,7 +747,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -928,6 +939,7 @@ name = "fitnesstrax" version = "0.3.0" dependencies = [ "async-channel", + "async-trait", "chrono", "chrono-tz", "dimensioned 0.8.0", @@ -1132,7 +1144,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -1367,7 +1379,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -2443,7 +2455,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -2648,7 +2660,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -2792,9 +2804,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] @@ -2836,9 +2848,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -3321,7 +3333,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -3720,9 +3732,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.41" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -3818,7 +3830,7 @@ checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -3942,7 +3954,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -4063,7 +4075,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -4433,7 +4445,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", "wasm-bindgen-shared", ] @@ -4467,7 +4479,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4708,7 +4720,7 @@ checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] diff --git a/fitnesstrax/app/Cargo.toml b/fitnesstrax/app/Cargo.toml index 1a1de9e..3966c0c 100644 --- a/fitnesstrax/app/Cargo.toml +++ b/fitnesstrax/app/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" [dependencies] adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] } async-channel = { version = "2.1" } +async-trait = { version = "0.1" } chrono = { version = "0.4" } chrono-tz = { version = "0.8" } dimensioned = { version = "0.8", features = [ "serde" ] } diff --git a/fitnesstrax/app/src/app.rs b/fitnesstrax/app/src/app.rs index 8dfb882..18b67ea 100644 --- a/fitnesstrax/app/src/app.rs +++ b/fitnesstrax/app/src/app.rs @@ -14,6 +14,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ +use async_trait::async_trait; use chrono::NaiveDate; use emseries::{time_range, Record, RecordId, Series, Timestamp}; use ft_core::TraxRecord; @@ -34,6 +35,32 @@ pub enum AppError { Unhandled, } +#[derive(Debug, Error)] +pub enum ReadError { + #[error("no database loaded")] + NoDatabase, +} + +#[derive(Debug, Error)] +pub enum WriteError { + #[error("no database loaded")] + NoDatabase, + #[error("unhandled error")] + Unhandled, +} + +#[async_trait] +pub trait RecordProvider: Send + Sync { + async fn records( + &self, + start: NaiveDate, + end: NaiveDate, + ) -> Result>, ReadError>; + async fn put_record(&self, record: TraxRecord) -> Result; + async fn update_record(&self, record: Record) -> Result<(), WriteError>; + async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>; +} + /// The real, headless application. This is where all of the logic will reside. #[derive(Clone)] pub struct App { @@ -57,11 +84,26 @@ impl App { } } - pub async fn records( + pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> { + let db_ref = self.database.clone(); + self.runtime + .spawn_blocking(move || { + let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?; + *db_ref.write().unwrap() = Some(db); + Ok(()) + }) + .await + .unwrap() + } +} + +#[async_trait] +impl RecordProvider for App { + async fn records( &self, start: NaiveDate, end: NaiveDate, - ) -> Result>, AppError> { + ) -> Result>, ReadError> { let db = self.database.clone(); self.runtime .spawn_blocking(move || { @@ -77,14 +119,14 @@ impl App { .collect::>>(); Ok(records) } else { - Err(AppError::NoDatabase) + Err(ReadError::NoDatabase) } }) .await .unwrap() } - pub async fn put_record(&self, record: TraxRecord) -> Result { + async fn put_record(&self, record: TraxRecord) -> Result { let db = self.database.clone(); self.runtime .spawn_blocking(move || { @@ -97,10 +139,10 @@ impl App { }) .await .unwrap() - .map_err(|_| AppError::Unhandled) + .map_err(|_| WriteError::Unhandled) } - pub async fn update_record(&self, record: Record) -> Result<(), AppError> { + async fn update_record(&self, record: Record) -> Result<(), WriteError> { let db = self.database.clone(); self.runtime .spawn_blocking(move || { @@ -112,18 +154,10 @@ impl App { }) .await .unwrap() - .map_err(|_| AppError::Unhandled) + .map_err(|_| WriteError::Unhandled) } - pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> { - let db_ref = self.database.clone(); - self.runtime - .spawn_blocking(move || { - let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?; - *db_ref.write().unwrap() = Some(db); - Ok(()) - }) - .await - .unwrap() + async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> { + unimplemented!() } } diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index 75f6dbb..2359f62 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -15,7 +15,8 @@ You should have received a copy of the GNU General Public License along with Fit */ use crate::{ - app::App, + app::{App, RecordProvider}, + types::DayInterval, view_models::DayDetailViewModel, views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView}, }; @@ -133,23 +134,22 @@ impl AppWindow { self.swap_main(view); } - fn show_historical_view(&self, records: Vec>) { - let view = View::Historical(HistoricalView::new(self.app.clone(), records, { + fn show_historical_view(&self, interval: DayInterval) { + let view = View::Historical(HistoricalView::new(self.app.clone(), interval, { let s = self.clone(); - Rc::new(move |date, records| { - let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); - layout.append(&adw::HeaderBar::new()); - // layout.append(&DayDetailView::new(date, records, s.app.clone())); - layout.append(&DayDetailView::new(DayDetailViewModel::new( - date, - records, - s.app.clone(), - ))); - let page = &adw::NavigationPage::builder() - .title(date.format("%Y-%m-%d").to_string()) - .child(&layout) - .build(); - s.navigation.push(page); + Rc::new(move |date| { + let s = s.clone(); + glib::spawn_future_local(async move { + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(&adw::HeaderBar::new()); + let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap(); + layout.append(&DayDetailView::new(view_model)); + let page = &adw::NavigationPage::builder() + .title(date.format("%Y-%m-%d").to_string()) + .child(&layout) + .build(); + s.navigation.push(page); + }); }) })); self.swap_main(view); @@ -161,10 +161,7 @@ impl AppWindow { async move { let end = Local::now().date_naive(); let start = end - Duration::days(7); - match s.app.records(start, end).await { - Ok(records) => s.show_historical_view(records), - Err(_) => s.show_welcome_view(), - } + s.show_historical_view(DayInterval { start, end }); } }); } diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 15eb9ce..db0edf3 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -18,6 +18,7 @@ You should have received a copy of the GNU General Public License along with Fit // use ft_core::TraxRecord; use crate::{ components::{steps_editor, weight_field, ActionGroup, Steps, WeightLabel}, + types::WeightFormatter, view_models::DayDetailViewModel, }; use glib::Object; @@ -162,7 +163,7 @@ impl DayDetail { let top_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .build(); - let weight_view = WeightLabel::new(view_model.weight()); + let weight_view = WeightLabel::new(view_model.weight().map(WeightFormatter::from)); top_row.append(&weight_view.widget()); let steps_view = Steps::new(view_model.steps()); @@ -222,12 +223,21 @@ impl DayDetail { pub struct DayEditPrivate { on_finished: RefCell>, + workout_rows: RefCell, + view_model: RefCell>, } impl Default for DayEditPrivate { fn default() -> Self { Self { on_finished: RefCell::new(Box::new(|| {})), + workout_rows: RefCell::new( + gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .hexpand(true) + .build(), + ), + view_model: RefCell::new(None), } } } @@ -257,6 +267,7 @@ impl DayEdit { s.set_hexpand(true); *s.imp().on_finished.borrow_mut() = Box::new(on_finished); + *s.imp().view_model.borrow_mut() = Some(view_model.clone()); s.append( &ActionGroup::builder() @@ -283,10 +294,10 @@ impl DayEdit { .orientation(gtk::Orientation::Horizontal) .build(); top_row.append( - &weight_field(view_model.weight(), { + &weight_field(view_model.weight().map(WeightFormatter::from), { let view_model = view_model.clone(); move |w| match w { - Some(w) => view_model.set_weight(w), + Some(w) => view_model.set_weight(*w), None => eprintln!("have not implemented record delete"), } }) diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index cf8ac0e..07a70cb 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,9 +14,11 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ -use crate::{app::App, types::WeightFormatter}; +use crate::app::{ReadError, RecordProvider, WriteError}; +use chrono::NaiveDate; +use dimensioned::si; use emseries::{Record, RecordId, Recordable}; -use ft_core::TraxRecord; +use ft_core::{RecordType, TimeDistance, TimeDistanceWorkoutType, TraxRecord}; use std::{ collections::HashMap, ops::Deref, @@ -28,7 +30,6 @@ enum RecordState { Original(Record), New(T), Updated(Record), - #[allow(unused)] Deleted(Record), } @@ -43,6 +44,15 @@ impl RecordState { } } + fn exists(&self) -> bool { + match self { + RecordState::Original(ref r) => true, + RecordState::New(ref r) => true, + RecordState::Updated(ref r) => true, + RecordState::Deleted(ref r) => false, + } + } + fn with_value(self, value: T) -> RecordState { match self { RecordState::Original(r) => RecordState::Updated(Record { data: value, ..r }), @@ -75,12 +85,20 @@ impl Deref for RecordState { } } -#[derive(Default)] -struct DayDetailViewModelInner {} +impl std::ops::DerefMut for RecordState { + fn deref_mut(&mut self) -> &mut Self::Target { + match self { + RecordState::Original(ref mut r) => &mut r.data, + RecordState::New(ref mut r) => r, + RecordState::Updated(ref mut r) => &mut r.data, + RecordState::Deleted(ref mut r) => &mut r.data, + } + } +} -#[derive(Clone, Default)] +#[derive(Clone)] pub struct DayDetailViewModel { - app: Option, + provider: Arc, pub date: chrono::NaiveDate, weight: Arc>>>, steps: Arc>>>, @@ -88,13 +106,28 @@ pub struct DayDetailViewModel { } impl DayDetailViewModel { - pub fn new(date: chrono::NaiveDate, records: Vec>, app: App) -> Self { + pub async fn new( + date: chrono::NaiveDate, + provider: impl RecordProvider + 'static, + ) -> Result { let (weight_records, records): (Vec>, Vec>) = - records.into_iter().partition(|r| r.data.is_weight()); + provider + .records(date, date) + .await? + .into_iter() + .partition(|r| r.data.is_weight()); let (step_records, records): (Vec>, Vec>) = records.into_iter().partition(|r| r.data.is_steps()); - Self { - app: Some(app), + + if weight_records.len() > 1 { + eprintln!("warning: multiple weight records found for {}. This is unsupported and the one presented is unpredictable.", date.format("%Y-%m-%d")); + } + if step_records.len() > 1 { + eprintln!("warning: multiple step records found for {}. This is unsupported and the one presented is unpredictable.", date.format("%Y-%m-%d")); + } + + Ok(Self { + provider: Arc::new(provider), date, weight: Arc::new(RwLock::new( weight_records @@ -121,25 +154,23 @@ impl DayDetailViewModel { .map(|r| (r.id.clone(), RecordState::Original(r))) .collect::>>(), )), - } + }) } - pub fn weight(&self) -> Option { - (*self.weight.read().unwrap()) - .as_ref() - .map(|w| WeightFormatter::from(w.weight)) + pub fn weight(&self) -> Option> { + (*self.weight.read().unwrap()).as_ref().map(|w| (*w).weight) } - pub fn set_weight(&self, new_weight: WeightFormatter) { + pub fn set_weight(&self, new_weight: si::Kilogram) { let mut record = self.weight.write().unwrap(); let new_record = match *record { Some(ref rstate) => rstate.clone().with_value(ft_core::Weight { date: self.date, - weight: *new_weight, + weight: new_weight, }), None => RecordState::New(ft_core::Weight { date: self.date, - weight: *new_weight, + weight: new_weight, }), }; *record = Some(new_record); @@ -164,73 +195,473 @@ impl DayDetailViewModel { *record = Some(new_record); } + pub fn new_time_distance(&self, type_: TimeDistanceWorkoutType) -> Record { + let id = RecordId::default(); + let workout = TimeDistance { + datetime: chrono::Local::now().into(), + distance: None, + duration: None, + comments: None, + }; + let tr = TraxRecord::from_time_distance(type_, workout.clone()); + self.records + .write() + .unwrap() + .insert(id.clone(), RecordState::New(tr)); + println!( + "records after new_time_distance: {:?}", + self.records.read().unwrap() + ); + Record { id, data: workout } + } + + pub fn update_time_distance(&self, workout: Record) { + let id = workout.id.clone(); + let data = workout.data.clone(); + + let mut record_set = self.records.write().unwrap(); + if let Some(record_state) = record_set.get(&id).clone() { + let updated_state = match **record_state { + TraxRecord::BikeRide(_) => { + Some(record_state.clone().with_value(TraxRecord::BikeRide(data))) + } + TraxRecord::Row(_) => Some(record_state.clone().with_value(TraxRecord::Row(data))), + TraxRecord::Run(_) => Some(record_state.clone().with_value(TraxRecord::Run(data))), + TraxRecord::Swim(_) => { + Some(record_state.clone().with_value(TraxRecord::Swim(data))) + } + TraxRecord::Walk(_) => { + Some(record_state.clone().with_value(TraxRecord::Walk(data))) + } + _ => None, + }; + + if let Some(updated_state) = updated_state { + record_set.insert(id, updated_state); + } + } + } + + pub fn time_distance_records( + &self, + type_: TimeDistanceWorkoutType, + ) -> Vec> { + self.records + .read() + .unwrap() + .iter() + .filter(|(_, record)| record.exists()) + .filter(|(_, workout_state)| workout_state.is_time_distance_type(type_)) + .filter_map(|(id, record_state)| match **record_state { + TraxRecord::BikeRide(ref workout) + | TraxRecord::Row(ref workout) + | TraxRecord::Run(ref workout) + | TraxRecord::Swim(ref workout) + | TraxRecord::Walk(ref workout) => Some(Record { + id: id.clone(), + data: workout.clone(), + }), + _ => None, + }) + .collect() + } + + pub fn time_distance_summary( + &self, + type_: TimeDistanceWorkoutType, + ) -> (si::Meter, si::Second) { + self.time_distance_records(type_).into_iter().fold( + (0. * si::M, 0. * si::S), + |(distance, duration), workout| match (workout.data.distance, workout.data.duration) { + (Some(distance_), Some(duration_)) => (distance + distance_, duration + duration_), + (Some(distance_), None) => (distance + distance_, duration), + (None, Some(duration_)) => (distance, duration + duration_), + (None, None) => (distance, duration), + }, + ) + } + + fn get_record(&self, id: &RecordId) -> Option> { + let record_set = self.records.read().unwrap(); + match record_set.get(&id) { + Some(record) => Some(Record { + id: id.clone(), + data: (**record).clone(), + }), + None => None, + } + } + + pub fn remove_record(&self, id: RecordId) { + let mut record_set = self.records.write().unwrap(); + let updated_record = match record_set.remove(&id) { + Some(RecordState::Original(r)) => Some(RecordState::Deleted(r)), + Some(RecordState::New(_)) => None, + Some(RecordState::Updated(r)) => Some(RecordState::Deleted(r)), + Some(RecordState::Deleted(r)) => Some(RecordState::Deleted(r)), + None => None, + }; + if let Some(updated_record) = updated_record { + record_set.insert(id, updated_record); + } + } + pub fn save(&self) { glib::spawn_future({ let s = self.clone(); - async move { - if let Some(app) = s.app { - let weight_record = s.weight.read().unwrap().clone(); - match weight_record { - Some(RecordState::New(weight)) => { - let _ = app.put_record(TraxRecord::Weight(weight)).await; - } - Some(RecordState::Original(_)) => {} - Some(RecordState::Updated(weight)) => { - let _ = app - .update_record(Record { - id: weight.id, - data: TraxRecord::Weight(weight.data), - }) - .await; - } - Some(RecordState::Deleted(_)) => {} - None => {} - } + async move { s.async_save().await } + }); + } - let steps_record = s.steps.read().unwrap().clone(); - match steps_record { - Some(RecordState::New(steps)) => { - let _ = app.put_record(TraxRecord::Steps(steps)).await; - } - Some(RecordState::Original(_)) => {} - Some(RecordState::Updated(steps)) => { - let _ = app - .update_record(Record { - id: steps.id, - data: TraxRecord::Steps(steps.data), - }) - .await; - } - Some(RecordState::Deleted(_)) => {} - None => {} - } + pub async fn async_save(&self) { + println!("async_save"); + let weight_record = self.weight.read().unwrap().clone(); + match weight_record { + Some(RecordState::New(data)) => { + let _ = self.provider.put_record(TraxRecord::Weight(data)).await; + } + Some(RecordState::Original(_)) => {} + Some(RecordState::Updated(weight)) => { + let _ = self + .provider + .update_record(Record { + id: weight.id, + data: TraxRecord::Weight(weight.data), + }) + .await; + } + Some(RecordState::Deleted(_)) => {} + None => {} + } - let records = s - .records - .write() - .unwrap() - .drain() - .map(|(_, record)| record) - .collect::>>(); + let steps_record = self.steps.read().unwrap().clone(); + match steps_record { + Some(RecordState::New(data)) => { + let _ = self.provider.put_record(TraxRecord::Steps(data)).await; + } + Some(RecordState::Original(_)) => {} + Some(RecordState::Updated(steps)) => { + let _ = self + .provider + .update_record(Record { + id: steps.id, + data: TraxRecord::Steps(steps.data), + }) + .await; + } + Some(RecordState::Deleted(_)) => {} + None => {} + } - for record in records { - match record { - RecordState::New(data) => { - let _ = app.put_record(data).await; - } - RecordState::Original(_) => {} - RecordState::Updated(r) => { - let _ = app.update_record(r.clone()).await; - } - RecordState::Deleted(_) => unimplemented!(), - } - } + let records = self + .records + .write() + .unwrap() + .drain() + .map(|(_, record)| record) + .collect::>>(); + + for record in records { + println!("saving record: {:?}", record); + match record { + RecordState::New(data) => { + let _ = self.provider.put_record(data).await; + } + RecordState::Original(_) => {} + RecordState::Updated(r) => { + let _ = self.provider.update_record(r.clone()).await; + } + RecordState::Deleted(r) => { + let _ = self.provider.delete_record(r.id).await; } } - }); + } } pub fn revert(&self) { unimplemented!(); } } + +#[cfg(test)] +mod test { + use super::*; + use async_trait::async_trait; + use chrono::{DateTime, FixedOffset, TimeZone}; + use dimensioned::si; + use emseries::Record; + + #[derive(Clone, Debug)] + struct MockProvider { + records: Arc>>>, + + put_records: Arc>>>, + updated_records: Arc>>>, + deleted_records: Arc>>, + } + + impl MockProvider { + fn new(records: Vec>) -> Self { + let record_map = records + .into_iter() + .map(|r| (r.id.clone(), r)) + .collect::>>(); + Self { + records: Arc::new(RwLock::new(record_map)), + put_records: Arc::new(RwLock::new(vec![])), + updated_records: Arc::new(RwLock::new(vec![])), + deleted_records: Arc::new(RwLock::new(vec![])), + } + } + } + + #[async_trait] + impl RecordProvider for MockProvider { + async fn records( + &self, + start: NaiveDate, + end: NaiveDate, + ) -> Result>, ReadError> { + let start = emseries::Timestamp::Date(start); + let end = emseries::Timestamp::Date(end); + Ok(self + .records + .read() + .unwrap() + .iter() + .map(|(_, r)| r) + .filter(|r| r.timestamp() >= start && r.timestamp() <= end) + .cloned() + .collect::>>()) + } + + async fn put_record(&self, record: TraxRecord) -> Result { + let id = RecordId::default(); + let record = Record { + id: id.clone(), + data: record, + }; + self.put_records.write().unwrap().push(record.clone()); + self.records.write().unwrap().insert(id.clone(), record); + Ok(id) + } + + async fn update_record(&self, record: Record) -> Result<(), WriteError> { + println!("updated record: {:?}", record); + self.updated_records.write().unwrap().push(record.clone()); + self.records + .write() + .unwrap() + .insert(record.id.clone(), record); + Ok(()) + } + + async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> { + self.deleted_records.write().unwrap().push(id.clone()); + let _ = self.records.write().unwrap().remove(&id); + Ok(()) + } + } + + async fn create_empty_view_model() -> (DayDetailViewModel, MockProvider) { + let provider = MockProvider::new(vec![]); + + let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(); + let model = DayDetailViewModel::new(oct_13, provider.clone()) + .await + .unwrap(); + (model, provider) + } + + async fn create_view_model() -> (DayDetailViewModel, MockProvider) { + let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap(); + let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(); + let oct_13_am: DateTime = oct_13 + .clone() + .and_hms_opt(3, 28, 0) + .unwrap() + .and_utc() + .with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap()); + let provider = MockProvider::new(vec![ + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_12.clone(), + weight: 93. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Weight(ft_core::Weight { + date: oct_13.clone(), + weight: 95. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Steps(ft_core::Steps { + date: oct_13.clone(), + count: 2500, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::BikeRide(ft_core::TimeDistance { + datetime: oct_13_am.clone(), + distance: Some(15000. * si::M), + duration: Some(3600. * si::S), + comments: Some("somecomments present".to_owned()), + }), + }, + ]); + let model = DayDetailViewModel::new(oct_13, provider.clone()) + .await + .unwrap(); + (model, provider) + } + + #[tokio::test] + async fn it_honors_only_the_first_weight_and_step_record() { + let (view_model, provider) = create_view_model().await; + assert_eq!(view_model.weight(), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(2500)); + } + + #[tokio::test] + async fn it_can_create_a_weight_and_stepcount() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!(view_model.weight(), None); + assert_eq!(view_model.steps(), None); + + view_model.set_weight(95. * si::KG); + view_model.set_steps(250); + + assert_eq!(view_model.weight(), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(250)); + + view_model.set_weight(93. * si::KG); + view_model.set_steps(255); + + assert_eq!(view_model.weight(), Some(93. * si::KG)); + assert_eq!(view_model.steps(), Some(255)); + + view_model.async_save().await; + + println!("provider: {:?}", provider); + assert_eq!(provider.put_records.read().unwrap().len(), 2); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + async fn it_can_construct_new_records() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + + let mut record = view_model.new_time_distance(TimeDistanceWorkoutType::BikeRide); + record.data.duration = Some(60. * si::S); + view_model.async_save().await; + + assert_eq!(provider.put_records.read().unwrap().len(), 1); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + async fn it_can_update_a_new_record_before_saving() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + + let mut record = view_model.new_time_distance(TimeDistanceWorkoutType::BikeRide); + record.data.duration = Some(60. * si::S); + view_model.update_time_distance(record.clone()); + let record = Record { + id: record.id, + data: TraxRecord::BikeRide(record.data), + }; + assert_eq!(view_model.get_record(&record.id), Some(record)); + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 60. * si::S) + ); + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::Run), + (0. * si::M, 0. * si::S) + ); + view_model.async_save().await; + + assert_eq!(provider.put_records.read().unwrap().len(), 1); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + async fn it_can_update_an_existing_record() { + let (view_model, provider) = create_view_model().await; + let mut workout = view_model + .time_distance_records(TimeDistanceWorkoutType::BikeRide) + .first() + .cloned() + .unwrap(); + + println!("found record: {:?}", workout); + + workout.data.duration = Some(1800. * si::S); + view_model.update_time_distance(workout.clone()); + + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (15000. * si::M, 1800. * si::S) + ); + + view_model.async_save().await; + + assert_eq!(provider.put_records.read().unwrap().len(), 0); + assert_eq!(provider.updated_records.read().unwrap().len(), 1); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + async fn it_can_remove_a_new_record() { + let (view_model, provider) = create_empty_view_model().await; + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + + let record = view_model.new_time_distance(TimeDistanceWorkoutType::BikeRide); + view_model.remove_record(record.id); + view_model.save(); + + assert_eq!(provider.put_records.read().unwrap().len(), 0); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 0); + } + + #[tokio::test] + async fn it_can_delete_an_existing_record() { + let (view_model, provider) = create_view_model().await; + let mut workout = view_model + .time_distance_records(TimeDistanceWorkoutType::BikeRide) + .first() + .cloned() + .unwrap(); + + view_model.remove_record(workout.id); + assert_eq!( + view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), + (0. * si::M, 0. * si::S) + ); + view_model.async_save().await; + + assert_eq!(provider.put_records.read().unwrap().len(), 0); + assert_eq!(provider.updated_records.read().unwrap().len(), 0); + assert_eq!(provider.deleted_records.read().unwrap().len(), 1); + } +} diff --git a/fitnesstrax/app/src/views/day_detail_view.rs b/fitnesstrax/app/src/views/day_detail_view.rs index ae213fe..0f9b19c 100644 --- a/fitnesstrax/app/src/views/day_detail_view.rs +++ b/fitnesstrax/app/src/views/day_detail_view.rs @@ -25,7 +25,7 @@ use std::cell::RefCell; #[derive(Default)] pub struct DayDetailViewPrivate { container: Singleton, - view_model: RefCell, + view_model: RefCell>, } #[glib::object_subclass] @@ -47,7 +47,7 @@ glib::wrapper! { impl DayDetailView { pub fn new(view_model: DayDetailViewModel) -> Self { let s: Self = Object::builder().build(); - *s.imp().view_model.borrow_mut() = view_model; + *s.imp().view_model.borrow_mut() = Some(view_model); s.append(&s.imp().container); @@ -57,20 +57,28 @@ impl DayDetailView { } fn view(&self) { - self.imp() - .container - .swap(&DayDetail::new(self.imp().view_model.borrow().clone(), { - let s = self.clone(); - move || s.edit() - })); + let view_model = self.imp().view_model.borrow(); + let view_model = view_model + .as_ref() + .expect("DayDetailView has not been initialized with a view_model") + .clone(); + + self.imp().container.swap(&DayDetail::new(view_model, { + let s = self.clone(); + move || s.edit() + })); } fn edit(&self) { - self.imp() - .container - .swap(&DayEdit::new(self.imp().view_model.borrow().clone(), { - let s = self.clone(); - move || s.view() - })); + let view_model = self.imp().view_model.borrow(); + let view_model = view_model + .as_ref() + .expect("DayDetailView has not been initialized with a view_model") + .clone(); + + self.imp().container.swap(&DayEdit::new(view_model, { + let s = self.clone(); + move || s.view() + })); } } diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index 2e80e40..cd7c782 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -59,27 +59,31 @@ impl ObjectSubclass for HistoricalViewPrivate { factory.connect_bind({ let app = s.app.clone(); move |_, list_item| { - let records = list_item - .downcast_ref::() - .expect("should be a ListItem") - .item() - .and_downcast::() - .expect("should be a DaySummary"); + let app = app.clone(); + let list_item = list_item.clone(); + glib::spawn_future_local(async move { + let date = list_item + .downcast_ref::() + .expect("should be a ListItem") + .item() + .and_downcast::() + .expect("should be a DaySummary"); - let summary = list_item - .downcast_ref::() - .expect("should be a ListItem") - .child() - .and_downcast::() - .expect("should be a DaySummary"); + let summary = list_item + .downcast_ref::() + .expect("should be a ListItem") + .child() + .and_downcast::() + .expect("should be a DaySummary"); - if let Some(app) = app.borrow().clone() { - summary.set_data(DayDetailViewModel::new( - records.date(), - records.records(), - app.clone(), - )); - } + if let Some(app) = app.borrow().clone() { + glib::spawn_future_local(async move { + let view_model = + DayDetailViewModel::new(date.date(), app).await.unwrap(); + summary.set_data(view_model); + }); + } + }); } }); @@ -96,13 +100,9 @@ glib::wrapper! { } impl HistoricalView { - pub fn new( - app: App, - records: Vec>, - on_select_day: Rc, - ) -> Self + pub fn new(app: App, interval: DayInterval, on_select_day: Rc) -> Self where - SelectFn: Fn(chrono::NaiveDate, Vec>) + 'static, + SelectFn: Fn(chrono::NaiveDate) + 'static, { let s: Self = Object::builder().build(); s.set_orientation(gtk::Orientation::Vertical); @@ -110,11 +110,8 @@ impl HistoricalView { *s.imp().app.borrow_mut() = Some(app); - let grouped_records = - GroupedRecords::new((*s.imp().time_window.borrow()).clone()).with_data(records); - - let mut model = gio::ListStore::new::(); - model.extend(grouped_records.items()); + let mut model = gio::ListStore::new::(); + model.extend(interval.days().map(Date::new)); s.imp() .list_view .set_model(Some(>k::NoSelection::new(Some(model)))); @@ -126,8 +123,8 @@ impl HistoricalView { // actually want to do here is to open a modal dialog that shows all of the details of // the day and which allows the user to edit items within that dialog. let item = s.model().unwrap().item(idx).unwrap(); - let records = item.downcast_ref::().unwrap(); - on_select_day(records.date(), records.records()); + let date = item.downcast_ref::().unwrap(); + on_select_day(date.date()); } }); @@ -136,12 +133,9 @@ impl HistoricalView { s } - pub fn set_records(&self, records: Vec>) { - println!("set_records: {:?}", records); - let grouped_records = - GroupedRecords::new((self.imp().time_window.borrow()).clone()).with_data(records); - let mut model = gio::ListStore::new::(); - model.extend(grouped_records.items()); + pub fn set_interval(&self, interval: DayInterval) { + let mut model = gio::ListStore::new::(); + model.extend(interval.days().map(Date::new)); self.imp() .list_view .set_model(Some(>k::NoSelection::new(Some(model)))); @@ -153,152 +147,30 @@ impl HistoricalView { } #[derive(Default)] -pub struct DayRecordsPrivate { +pub struct DatePrivate { date: RefCell, - records: RefCell>>, } #[glib::object_subclass] -impl ObjectSubclass for DayRecordsPrivate { - const NAME: &'static str = "DayRecords"; - type Type = DayRecords; +impl ObjectSubclass for DatePrivate { + const NAME: &'static str = "Date"; + type Type = Date; } -impl ObjectImpl for DayRecordsPrivate {} +impl ObjectImpl for DatePrivate {} glib::wrapper! { - pub struct DayRecords(ObjectSubclass); + pub struct Date(ObjectSubclass); } -impl DayRecords { - pub fn new(date: chrono::NaiveDate, records: Vec>) -> Self { +impl Date { + pub fn new(date: chrono::NaiveDate) -> Self { let s: Self = Object::builder().build(); - *s.imp().date.borrow_mut() = date; - *s.imp().records.borrow_mut() = records; - s } pub fn date(&self) -> chrono::NaiveDate { *self.imp().date.borrow() } - - pub fn records(&self) -> Vec> { - self.imp().records.borrow().clone() - } - - pub fn add_record(&self, record: Record) { - self.imp().records.borrow_mut().push(record); - } -} - -// This isn't feeling quite right. DayRecords is a glib object, but I'm not sure that I want to -// really be passing that around. It seems not generic enough. I feel like this whole grouped -// records thing can be made more generic. -struct GroupedRecords { - interval: DayInterval, - data: HashMap, -} - -impl GroupedRecords { - fn new(interval: DayInterval) -> Self { - let mut s = Self { - interval: interval.clone(), - data: HashMap::new(), - }; - interval.days().for_each(|date| { - let _ = s.data.insert(date, DayRecords::new(date, vec![])); - }); - s - } - - fn with_data(mut self, records: Vec>) -> Self { - records.into_iter().for_each(|record| { - self.data - .entry(record.date()) - .and_modify(|entry: &mut DayRecords| (*entry).add_record(record.clone())) - .or_insert(DayRecords::new(record.date(), vec![record])); - }); - - self - } - - fn items(&self) -> impl Iterator + '_ { - self.interval.days().map(|date| { - self.data - .get(&date) - .cloned() - .unwrap_or(DayRecords::new(date, vec![])) - }) - } -} - -#[cfg(test)] -mod test { - use super::GroupedRecords; - use crate::types::DayInterval; - use chrono::{FixedOffset, NaiveDate, TimeZone}; - use dimensioned::si::{KG, M, S}; - use emseries::{Record, RecordId}; - use ft_core::{Steps, TimeDistance, TraxRecord, Weight}; - - #[test] - fn groups_records() { - let records = vec![ - Record { - id: RecordId::default(), - data: TraxRecord::Steps(Steps { - date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(), - count: 1500, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Weight(Weight { - date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(), - weight: 85. * KG, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Weight(Weight { - date: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(), - weight: 86. * KG, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::BikeRide(TimeDistance { - datetime: FixedOffset::west_opt(10 * 60 * 60) - .unwrap() - .with_ymd_and_hms(2019, 6, 15, 12, 0, 0) - .unwrap(), - distance: Some(1000. * M), - duration: Some(150. * S), - comments: Some("Test Comments".to_owned()), - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::BikeRide(TimeDistance { - datetime: FixedOffset::west_opt(10 * 60 * 60) - .unwrap() - .with_ymd_and_hms(2019, 6, 15, 23, 0, 0) - .unwrap(), - distance: Some(1000. * M), - duration: Some(150. * S), - comments: Some("Test Comments".to_owned()), - }), - }, - ]; - - let groups = GroupedRecords::new(DayInterval { - start: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(), - end: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(), - }) - .with_data(records) - .data; - assert_eq!(groups.len(), 3); - } } diff --git a/fitnesstrax/core/src/lib.rs b/fitnesstrax/core/src/lib.rs index 207f483..8e2144a 100644 --- a/fitnesstrax/core/src/lib.rs +++ b/fitnesstrax/core/src/lib.rs @@ -1,4 +1,4 @@ mod legacy; mod types; -pub use types::{RecordType, Steps, TimeDistance, TraxRecord, Weight}; +pub use types::{RecordType, Steps, TimeDistance, TimeDistanceWorkoutType, TraxRecord, Weight}; diff --git a/fitnesstrax/core/src/types.rs b/fitnesstrax/core/src/types.rs index b0959f7..a475dba 100644 --- a/fitnesstrax/core/src/types.rs +++ b/fitnesstrax/core/src/types.rs @@ -57,6 +57,16 @@ pub struct TimeDistance { pub comments: Option, } +impl Recordable for TimeDistance { + fn timestamp(&self) -> Timestamp { + Timestamp::DateTime(self.datetime) + } + + fn tags(&self) -> Vec { + vec![] + } +} + /// A singular daily weight measurement. Weight changes slowly enough that it seems unlikely to /// need to track more than a single weight in a day. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -86,6 +96,15 @@ pub enum RecordType { Weight, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TimeDistanceWorkoutType { + BikeRide, + Row, + Run, + Swim, + Walk, +} + /// The unified data structure for all records that are part of the app. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum TraxRecord { @@ -99,6 +118,16 @@ pub enum TraxRecord { } impl TraxRecord { + pub fn from_time_distance(type_: TimeDistanceWorkoutType, workout: TimeDistance) -> Self { + match type_ { + TimeDistanceWorkoutType::BikeRide => Self::BikeRide(workout), + TimeDistanceWorkoutType::Run => Self::Run(workout), + TimeDistanceWorkoutType::Row => Self::Row(workout), + TimeDistanceWorkoutType::Swim => Self::Swim(workout), + TimeDistanceWorkoutType::Walk => Self::Walk(workout), + } + } + pub fn workout_type(&self) -> RecordType { match self { TraxRecord::BikeRide(_) => RecordType::BikeRide, @@ -118,6 +147,27 @@ impl TraxRecord { pub fn is_steps(&self) -> bool { matches!(self, TraxRecord::Steps(_)) } + + pub fn is_time_distance(&self) -> bool { + matches!( + self, + TraxRecord::BikeRide(_) + | TraxRecord::Row(_) + | TraxRecord::Run(_) + | TraxRecord::Swim(_) + | TraxRecord::Walk(_) + ) + } + + pub fn is_time_distance_type(&self, type_: TimeDistanceWorkoutType) -> bool { + match type_ { + TimeDistanceWorkoutType::BikeRide => matches!(self, TraxRecord::BikeRide(_)), + TimeDistanceWorkoutType::Row => matches!(self, TraxRecord::Row(_)), + TimeDistanceWorkoutType::Run => matches!(self, TraxRecord::Run(_)), + TimeDistanceWorkoutType::Swim => matches!(self, TraxRecord::Swim(_)), + TimeDistanceWorkoutType::Walk => matches!(self, TraxRecord::Walk(_)), + } + } } impl Recordable for TraxRecord {