From 304008c67429acddaf41d4dc6976fe06e0c0583f Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Mon, 29 Jan 2024 09:18:36 -0500 Subject: [PATCH 1/7] The view model can no longer be initialized without an app --- fitnesstrax/app/src/components/day.rs | 10 ++ fitnesstrax/app/src/view_models/day_detail.rs | 111 +++++++++--------- fitnesstrax/app/src/views/day_detail_view.rs | 36 +++--- 3 files changed, 86 insertions(+), 71 deletions(-) diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 15eb9ce..d8d547f 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -222,12 +222,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 +266,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() diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index cf8ac0e..0e9b5c9 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -75,12 +75,9 @@ impl Deref for RecordState { } } -#[derive(Default)] -struct DayDetailViewModelInner {} - -#[derive(Clone, Default)] +#[derive(Clone)] pub struct DayDetailViewModel { - app: Option, + app: App, pub date: chrono::NaiveDate, weight: Arc>>>, steps: Arc>>>, @@ -94,7 +91,7 @@ impl DayDetailViewModel { let (step_records, records): (Vec>, Vec>) = records.into_iter().partition(|r| r.data.is_steps()); Self { - app: Some(app), + app, date, weight: Arc::new(RwLock::new( weight_records @@ -168,62 +165,62 @@ impl DayDetailViewModel { 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 => {} + let weight_record = s.weight.read().unwrap().clone(); + match weight_record { + Some(RecordState::New(data)) => { + let _ = s.app.put_record(TraxRecord::Weight(data)).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 => {} + Some(RecordState::Original(_)) => {} + Some(RecordState::Updated(weight)) => { + let _ = s + .app + .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 = s.steps.read().unwrap().clone(); + match steps_record { + Some(RecordState::New(data)) => { + let _ = s.app.put_record(TraxRecord::Steps(data)).await; + } + Some(RecordState::Original(_)) => {} + Some(RecordState::Updated(steps)) => { + let _ = s + .app + .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 = s + .records + .write() + .unwrap() + .drain() + .map(|(_, record)| record) + .collect::>>(); + + for record in records { + match record { + RecordState::New(data) => { + let _ = s.app.put_record(data).await; } + RecordState::Original(_) => {} + RecordState::Updated(r) => { + let _ = s.app.update_record(r.clone()).await; + } + RecordState::Deleted(_) => unimplemented!(), } } } 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() + })); } } -- 2.44.1 From c1e797f3aeae40677f4512748accb2f0061148d5 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 1 Feb 2024 09:27:40 -0500 Subject: [PATCH 2/7] DayDetailViewModel now ignores records and directly retrieves data from App This is preparatory work. Having the view model directly retrieve data both adds a degree of symmetry (it both gets data from and sends data to the app) and makes it possible for the view model to refresh itself when needing to revert data or after saving data. --- fitnesstrax/app/src/app_window.rs | 27 +++++++------ fitnesstrax/app/src/view_models/day_detail.rs | 4 +- fitnesstrax/app/src/views/historical_view.rs | 39 ++++++++++--------- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index 75f6dbb..c473062 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -136,20 +136,19 @@ impl AppWindow { fn show_historical_view(&self, records: Vec>) { let view = View::Historical(HistoricalView::new(self.app.clone(), records, { 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, _records| { + 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; + 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); diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index 0e9b5c9..fa5c39d 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -85,7 +85,9 @@ pub struct DayDetailViewModel { } impl DayDetailViewModel { - pub fn new(date: chrono::NaiveDate, records: Vec>, app: App) -> Self { + pub async fn new(date: chrono::NaiveDate, app: App) -> Self { + let records = app.records(date, date).await.unwrap(); + let (weight_records, records): (Vec>, Vec>) = records.into_iter().partition(|r| r.data.is_weight()); let (step_records, records): (Vec>, Vec>) = diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index 2e80e40..537481e 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -59,27 +59,28 @@ 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 records = 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() { + let view_model = DayDetailViewModel::new(records.date(), app.clone()).await; + summary.set_data(view_model); + } + }); } }); -- 2.44.1 From 96317f569233a73e57e04558d301bfb4a32c5508 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 1 Feb 2024 10:08:18 -0500 Subject: [PATCH 3/7] Finish removing the previous record grouping Now that the DayDetailViewModel knows to retrieve its own records, the grouping functions, and passing groups of records around, no longer make sens. --- fitnesstrax/app/src/app_window.rs | 12 +- fitnesstrax/app/src/views/historical_view.rs | 172 +++---------------- 2 files changed, 25 insertions(+), 159 deletions(-) diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index c473062..a747262 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -16,6 +16,7 @@ You should have received a copy of the GNU General Public License along with Fit use crate::{ app::App, + types::DayInterval, view_models::DayDetailViewModel, views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView}, }; @@ -133,10 +134,10 @@ 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| { + Rc::new(move |date| { let s = s.clone(); glib::spawn_future_local(async move { let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -160,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/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index 537481e..315ac00 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -62,11 +62,11 @@ impl ObjectSubclass for HistoricalViewPrivate { let app = app.clone(); let list_item = list_item.clone(); glib::spawn_future_local(async move { - let records = list_item + let date = list_item .downcast_ref::() .expect("should be a ListItem") .item() - .and_downcast::() + .and_downcast::() .expect("should be a DaySummary"); let summary = list_item @@ -77,7 +77,7 @@ impl ObjectSubclass for HistoricalViewPrivate { .expect("should be a DaySummary"); if let Some(app) = app.borrow().clone() { - let view_model = DayDetailViewModel::new(records.date(), app.clone()).await; + let view_model = DayDetailViewModel::new(date.date(), app.clone()).await; summary.set_data(view_model); } }); @@ -97,13 +97,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); @@ -111,11 +107,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)))); @@ -127,8 +120,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()); } }); @@ -137,12 +130,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_intervals(&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)))); @@ -154,152 +144,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); - } } -- 2.44.1 From 24276d172b4ddfb839f264c41879a9546aa8682f Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 1 Feb 2024 10:12:35 -0500 Subject: [PATCH 4/7] Introduce the RecordProvider interface DayDetailViewModel needs testing. I've worked out an improved API, and a set of tests to go along with it, and those can be made more easily with a mockable RecordProvider. So, in addition to stubbing out a bunch of tests, I've also created RecordProvider, mocked it, and implemented it for App. --- Cargo.lock | 50 ++++-- fitnesstrax/app/Cargo.toml | 1 + fitnesstrax/app/src/app.rs | 68 +++++-- fitnesstrax/app/src/app_window.rs | 4 +- fitnesstrax/app/src/view_models/day_detail.rs | 167 ++++++++++++++++-- fitnesstrax/app/src/views/historical_view.rs | 9 +- 6 files changed, 243 insertions(+), 56 deletions(-) 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 a747262..2359f62 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -15,7 +15,7 @@ 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}, @@ -142,7 +142,7 @@ impl AppWindow { 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; + 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()) diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index fa5c39d..46aee59 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,7 +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}, + types::WeightFormatter, +}; +use chrono::NaiveDate; use emseries::{Record, RecordId, Recordable}; use ft_core::TraxRecord; use std::{ @@ -77,7 +81,7 @@ impl Deref for RecordState { #[derive(Clone)] pub struct DayDetailViewModel { - app: App, + provider: Arc, pub date: chrono::NaiveDate, weight: Arc>>>, steps: Arc>>>, @@ -85,15 +89,20 @@ pub struct DayDetailViewModel { } impl DayDetailViewModel { - pub async fn new(date: chrono::NaiveDate, app: App) -> Self { - let records = app.records(date, date).await.unwrap(); - + 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, + Ok(Self { + provider: Arc::new(provider), date, weight: Arc::new(RwLock::new( weight_records @@ -120,7 +129,7 @@ impl DayDetailViewModel { .map(|r| (r.id.clone(), RecordState::Original(r))) .collect::>>(), )), - } + }) } pub fn weight(&self) -> Option { @@ -170,12 +179,12 @@ impl DayDetailViewModel { let weight_record = s.weight.read().unwrap().clone(); match weight_record { Some(RecordState::New(data)) => { - let _ = s.app.put_record(TraxRecord::Weight(data)).await; + let _ = s.provider.put_record(TraxRecord::Weight(data)).await; } Some(RecordState::Original(_)) => {} Some(RecordState::Updated(weight)) => { let _ = s - .app + .provider .update_record(Record { id: weight.id, data: TraxRecord::Weight(weight.data), @@ -189,12 +198,12 @@ impl DayDetailViewModel { let steps_record = s.steps.read().unwrap().clone(); match steps_record { Some(RecordState::New(data)) => { - let _ = s.app.put_record(TraxRecord::Steps(data)).await; + let _ = s.provider.put_record(TraxRecord::Steps(data)).await; } Some(RecordState::Original(_)) => {} Some(RecordState::Updated(steps)) => { let _ = s - .app + .provider .update_record(Record { id: steps.id, data: TraxRecord::Steps(steps.data), @@ -216,11 +225,11 @@ impl DayDetailViewModel { for record in records { match record { RecordState::New(data) => { - let _ = s.app.put_record(data).await; + let _ = s.provider.put_record(data).await; } RecordState::Original(_) => {} RecordState::Updated(r) => { - let _ = s.app.update_record(r.clone()).await; + let _ = s.provider.update_record(r.clone()).await; } RecordState::Deleted(_) => unimplemented!(), } @@ -233,3 +242,131 @@ impl DayDetailViewModel { unimplemented!(); } } + +#[cfg(test)] +mod test { + use super::*; + use async_trait::async_trait; + use dimensioned::si; + use emseries::Record; + + struct MockProvider { + records: 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 + .iter() + .filter(|r| r.timestamp() >= start && r.timestamp() <= end) + .cloned() + .collect::>>()) + } + + async fn put_record(&self, record: TraxRecord) -> Result { + Err(WriteError::NoDatabase) + } + + async fn update_record(&self, record: Record) -> Result<(), WriteError> { + Err(WriteError::NoDatabase) + } + + async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> { + Err(WriteError::NoDatabase) + } + } + + async fn with_view_model(test: TestFn) + where + TestFn: Fn(DayDetailViewModel), + { + 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 provider = MockProvider { + records: 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::Weight(ft_core::Weight { + date: oct_13.clone(), + weight: 91. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Steps(ft_core::Steps { + date: oct_13.clone(), + count: 2750, + }), + }, + ], + }; + let model = DayDetailViewModel::new(oct_13, provider).await.unwrap(); + test(model) + } + + #[tokio::test] + async fn it_honors_only_the_first_weight_and_step_record() { + with_view_model(|view_model| { + assert_eq!(view_model.weight().map(|val| *val), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(2500)); + }) + .await; + } + + #[test] + #[ignore] + fn it_enforces_one_weight_and_stepcount_per_day() {} + + #[test] + #[ignore] + fn it_can_construct_new_records() {} + + #[test] + #[ignore] + fn it_can_update_an_existing_record() {} + + #[test] + #[ignore] + fn it_can_remove_a_new_record() {} + + #[test] + #[ignore] + fn it_can_delete_an_existing_record() {} + + #[test] + #[ignore] + fn it_retrieve_records_by_workout() {} + + #[test] + #[ignore] + fn it_summarizes_records_by_workout_type() {} +} diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index 315ac00..cd7c782 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -77,8 +77,11 @@ impl ObjectSubclass for HistoricalViewPrivate { .expect("should be a DaySummary"); if let Some(app) = app.borrow().clone() { - let view_model = DayDetailViewModel::new(date.date(), app.clone()).await; - summary.set_data(view_model); + glib::spawn_future_local(async move { + let view_model = + DayDetailViewModel::new(date.date(), app).await.unwrap(); + summary.set_data(view_model); + }); } }); } @@ -130,7 +133,7 @@ impl HistoricalView { s } - pub fn set_intervals(&self, interval: DayInterval) { + pub fn set_interval(&self, interval: DayInterval) { let mut model = gio::ListStore::new::(); model.extend(interval.days().map(Date::new)); self.imp() -- 2.44.1 From 3db870d79084b8b6e8dba898524cfe5d5988dbc5 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Sat, 3 Feb 2024 15:28:33 -0500 Subject: [PATCH 5/7] Set up time distance operations and tests --- fitnesstrax/app/src/components/day.rs | 7 +- fitnesstrax/app/src/view_models/day_detail.rs | 548 +++++++++++++----- fitnesstrax/core/src/lib.rs | 2 +- fitnesstrax/core/src/types.rs | 50 ++ 4 files changed, 459 insertions(+), 148 deletions(-) diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index d8d547f..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()); @@ -293,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 46aee59..2cd7224 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,13 +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::{ReadError, RecordProvider, WriteError}, - 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, @@ -79,6 +77,17 @@ impl Deref for RecordState { } } +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)] pub struct DayDetailViewModel { provider: Arc, @@ -132,22 +141,20 @@ impl DayDetailViewModel { }) } - 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); @@ -172,72 +179,163 @@ 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(); + + self.records + .write() + .unwrap() + .entry(id) + .and_modify(|r| match **r { + TraxRecord::BikeRide(ref mut v) => *v = data, + TraxRecord::Row(ref mut v) => *v = data, + TraxRecord::Run(ref mut v) => *v = data, + TraxRecord::Swim(ref mut v) => *v = data, + TraxRecord::Walk(ref mut v) => *v = data, + _ => {} + }); + } + + pub fn time_distance_records(&self, type_: RecordType) -> Vec> { + unimplemented!("time_distance_records") + } + + pub fn time_distance_summary( + &self, + type_: TimeDistanceWorkoutType, + ) -> (si::Meter, si::Second) { + self.records + .read() + .unwrap() + .iter() + .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(workout), + _ => None, + }) + .fold((0. * si::M, 0. * si::S), |(distance, duration), workout| { + println!("folding workout: {:?}", workout); + match (workout.distance, workout.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) { + unimplemented!("remove_record") + } + pub fn save(&self) { glib::spawn_future({ let s = self.clone(); - async move { - let weight_record = s.weight.read().unwrap().clone(); - match weight_record { - Some(RecordState::New(data)) => { - let _ = s.provider.put_record(TraxRecord::Weight(data)).await; - } - Some(RecordState::Original(_)) => {} - Some(RecordState::Updated(weight)) => { - let _ = s - .provider - .update_record(Record { - id: weight.id, - data: TraxRecord::Weight(weight.data), - }) - .await; - } - Some(RecordState::Deleted(_)) => {} - None => {} - } - - let steps_record = s.steps.read().unwrap().clone(); - match steps_record { - Some(RecordState::New(data)) => { - let _ = s.provider.put_record(TraxRecord::Steps(data)).await; - } - Some(RecordState::Original(_)) => {} - Some(RecordState::Updated(steps)) => { - let _ = s - .provider - .update_record(Record { - id: steps.id, - data: TraxRecord::Steps(steps.data), - }) - .await; - } - Some(RecordState::Deleted(_)) => {} - None => {} - } - - let records = s - .records - .write() - .unwrap() - .drain() - .map(|(_, record)| record) - .collect::>>(); - - for record in records { - match record { - RecordState::New(data) => { - let _ = s.provider.put_record(data).await; - } - RecordState::Original(_) => {} - RecordState::Updated(r) => { - let _ = s.provider.update_record(r.clone()).await; - } - RecordState::Deleted(_) => unimplemented!(), - } - } - } + async move { s.async_save().await } }); } + 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 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 => {} + } + + 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(_) => unimplemented!(), + } + } + } + pub fn revert(&self) { unimplemented!(); } @@ -247,11 +345,32 @@ impl DayDetailViewModel { 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: Vec>, + 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] @@ -265,14 +384,24 @@ mod test { 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 { - Err(WriteError::NoDatabase) + 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> { @@ -284,89 +413,220 @@ mod test { } } - async fn with_view_model(test: TestFn) - where - TestFn: Fn(DayDetailViewModel), - { + 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 provider = MockProvider { - records: 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::Weight(ft_core::Weight { - date: oct_13.clone(), - weight: 91. * si::KG, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Steps(ft_core::Steps { - date: oct_13.clone(), - count: 2750, - }), - }, - ], - }; - let model = DayDetailViewModel::new(oct_13, provider).await.unwrap(); - test(model) + 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::Weight(ft_core::Weight { + date: oct_13.clone(), + weight: 91. * si::KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Steps(ft_core::Steps { + date: oct_13.clone(), + count: 2750, + }), + }, + 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() { - with_view_model(|view_model| { - assert_eq!(view_model.weight().map(|val| *val), Some(95. * si::KG)); - assert_eq!(view_model.steps(), Some(2500)); - }) - .await; + let (view_model, provider) = create_view_model().await; + assert_eq!(view_model.weight(), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(2500)); } - #[test] - #[ignore] - fn it_enforces_one_weight_and_stepcount_per_day() {} + #[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); - #[test] - #[ignore] - fn it_can_construct_new_records() {} + view_model.set_weight(95. * si::KG); + view_model.set_steps(250); - #[test] - #[ignore] - fn it_can_update_an_existing_record() {} + assert_eq!(view_model.weight(), Some(95. * si::KG)); + assert_eq!(view_model.steps(), Some(250)); - #[test] - #[ignore] - fn it_can_remove_a_new_record() {} + view_model.set_weight(93. * si::KG); + view_model.set_steps(255); - #[test] - #[ignore] - fn it_can_delete_an_existing_record() {} + assert_eq!(view_model.weight(), Some(93. * si::KG)); + assert_eq!(view_model.steps(), Some(255)); - #[test] - #[ignore] - fn it_retrieve_records_by_workout() {} + view_model.async_save().await; - #[test] + 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] #[ignore] - fn it_summarizes_records_by_workout_type() {} + async fn it_can_update_an_existing_record() { + let (view_model, provider) = create_view_model().await; + let mut workout = view_model + .time_distance_records(RecordType::BikeRide) + .first() + .cloned() + .unwrap(); + + 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.save(); + + 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] + #[ignore] + 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] + #[ignore] + async fn it_can_delete_an_existing_record() { + let (view_model, provider) = create_view_model().await; + let mut workout = view_model + .time_distance_records(RecordType::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.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(), 1); + } } 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 { -- 2.44.1 From a8bf540517cd1dca12a9475ecc3126ea9e276e69 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Wed, 7 Feb 2024 08:28:38 -0500 Subject: [PATCH 6/7] Remove test that steps and weights are honored correctly Step and weight records may be presented in any order. Any test that tries to enforce that one gets presented before teh other can't cannot succeed. So, I've removed that test and instead put in a warning that will appear when the view model gets loaded. --- fitnesstrax/app/src/view_models/day_detail.rs | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index 2cd7224..c4223e9 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -110,6 +110,14 @@ impl DayDetailViewModel { .partition(|r| r.data.is_weight()); let (step_records, records): (Vec>, Vec>) = records.into_iter().partition(|r| r.data.is_steps()); + + 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, @@ -454,20 +462,6 @@ mod test { count: 2500, }), }, - Record { - id: RecordId::default(), - data: TraxRecord::Weight(ft_core::Weight { - date: oct_13.clone(), - weight: 91. * si::KG, - }), - }, - Record { - id: RecordId::default(), - data: TraxRecord::Steps(ft_core::Steps { - date: oct_13.clone(), - count: 2750, - }), - }, Record { id: RecordId::default(), data: TraxRecord::BikeRide(ft_core::TimeDistance { -- 2.44.1 From 1d6155d9e55a1c185725c499a454e72e3af36c8c Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Wed, 7 Feb 2024 09:29:08 -0500 Subject: [PATCH 7/7] Finish the update and delete view model functions --- fitnesstrax/app/src/view_models/day_detail.rs | 125 ++++++++++++------ 1 file changed, 83 insertions(+), 42 deletions(-) diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index c4223e9..07a70cb 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -30,7 +30,6 @@ enum RecordState { Original(Record), New(T), Updated(Record), - #[allow(unused)] Deleted(Record), } @@ -45,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 }), @@ -211,52 +219,66 @@ impl DayDetailViewModel { let id = workout.id.clone(); let data = workout.data.clone(); - self.records - .write() - .unwrap() - .entry(id) - .and_modify(|r| match **r { - TraxRecord::BikeRide(ref mut v) => *v = data, - TraxRecord::Row(ref mut v) => *v = data, - TraxRecord::Run(ref mut v) => *v = data, - TraxRecord::Swim(ref mut v) => *v = data, - TraxRecord::Walk(ref mut v) => *v = data, - _ => {} - }); + 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_: RecordType) -> Vec> { - unimplemented!("time_distance_records") - } - - pub fn time_distance_summary( + pub fn time_distance_records( &self, type_: TimeDistanceWorkoutType, - ) -> (si::Meter, si::Second) { + ) -> 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(workout), + | TraxRecord::Walk(ref workout) => Some(Record { + id: id.clone(), + data: workout.clone(), + }), _ => None, }) - .fold((0. * si::M, 0. * si::S), |(distance, duration), workout| { - println!("folding workout: {:?}", workout); - match (workout.distance, workout.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), - } - }) + .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> { @@ -271,7 +293,17 @@ impl DayDetailViewModel { } pub fn remove_record(&self, id: RecordId) { - unimplemented!("remove_record") + 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) { @@ -339,7 +371,9 @@ impl DayDetailViewModel { RecordState::Updated(r) => { let _ = self.provider.update_record(r.clone()).await; } - RecordState::Deleted(_) => unimplemented!(), + RecordState::Deleted(r) => { + let _ = self.provider.delete_record(r.id).await; + } } } } @@ -413,11 +447,19 @@ mod test { } async fn update_record(&self, record: Record) -> Result<(), WriteError> { - Err(WriteError::NoDatabase) + 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> { - Err(WriteError::NoDatabase) + self.deleted_records.write().unwrap().push(id.clone()); + let _ = self.records.write().unwrap().remove(&id); + Ok(()) } } @@ -560,15 +602,16 @@ mod test { } #[tokio::test] - #[ignore] async fn it_can_update_an_existing_record() { let (view_model, provider) = create_view_model().await; let mut workout = view_model - .time_distance_records(RecordType::BikeRide) + .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()); @@ -577,7 +620,7 @@ mod test { (15000. * si::M, 1800. * si::S) ); - view_model.save(); + view_model.async_save().await; assert_eq!(provider.put_records.read().unwrap().len(), 0); assert_eq!(provider.updated_records.read().unwrap().len(), 1); @@ -585,7 +628,6 @@ mod test { } #[tokio::test] - #[ignore] async fn it_can_remove_a_new_record() { let (view_model, provider) = create_empty_view_model().await; assert_eq!( @@ -603,11 +645,10 @@ mod test { } #[tokio::test] - #[ignore] async fn it_can_delete_an_existing_record() { let (view_model, provider) = create_view_model().await; let mut workout = view_model - .time_distance_records(RecordType::BikeRide) + .time_distance_records(TimeDistanceWorkoutType::BikeRide) .first() .cloned() .unwrap(); @@ -617,7 +658,7 @@ mod test { view_model.time_distance_summary(TimeDistanceWorkoutType::BikeRide), (0. * si::M, 0. * si::S) ); - view_model.save(); + view_model.async_save().await; assert_eq!(provider.put_records.read().unwrap().len(), 0); assert_eq!(provider.updated_records.read().unwrap().len(), 0); -- 2.44.1