diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4d9636b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.showUnlinkedFileNotification": false +} \ No newline at end of file diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index 23b0492..9b230ea 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -16,9 +16,8 @@ You should have received a copy of the GNU General Public License along with Fit use crate::{ app::App, - views::{ - DayDetailView, DayDetailViewModel, HistoricalView, PlaceholderView, View, WelcomeView, - }, + view_models::DayDetailViewModel, + views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView}, }; use adw::prelude::*; use chrono::{Duration, Local}; diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 9fa601f..f0fe13a 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -17,8 +17,8 @@ You should have received a copy of the GNU General Public License along with Fit // use chrono::NaiveDate; // use ft_core::TraxRecord; use crate::{ - components::{ActionGroup, TimeDistanceView, Weight}, - views::DayDetailViewModel, + components::{ActionGroup, Weight}, + view_models::DayDetailViewModel, }; use emseries::Record; use glib::Object; @@ -257,67 +257,33 @@ impl DayEdit { s.append( &ActionGroup::builder() .primary_action("Save", { - /* - let s = s.clone(); - let records = records.clone(); - move || { - let weight_record = records.iter().find_map(|record| match record { - Record { - id, - data: ft_core::TraxRecord::Weight(w), - } => Some((id, w)), - _ => None, - }); - - let weight = s.imp().weight.value(); - - if let Some(weight) = weight { - match weight_record { - Some((id, _)) => on_update_record(Record { - id: id.clone(), - data: ft_core::TraxRecord::Weight(ft_core::Weight { - date, - weight, - }), - }), - None => { - on_put_record(ft_core::TraxRecord::Weight(ft_core::Weight { - date, - weight, - })) - } - } - }; - } - */ let s = s.clone(); + let view_model = view_model.clone(); move || { + view_model.save(); s.finish(); } }) .secondary_action("Cancel", { let s = s.clone(); - move || s.finish() + let view_model = view_model.clone(); + move || { + view_model.revert(); + s.finish(); + } }) .build(), ); - /* - let weight_record = records.iter().find_map(|record| match record { - Record { - id, - data: ft_core::TraxRecord::Weight(record), - } => Some((id.clone(), record.clone())), - _ => None, - }); - - match weight_record { - Some((_id, data)) => s.imp().weight.set_value(Some(data.weight)), - None => s.imp().weight.set_value(None), - }; - s.append(&s.imp().weight.widget()); - */ - s.append(&WeightEdit::new(view_model.weight()).widget()); + s.append( + &WeightEdit::new(view_model.weight(), { + let view_model = view_model.clone(); + move |w| { + view_model.set_weight(w); + } + }) + .widget(), + ); s } diff --git a/fitnesstrax/app/src/components/text_entry.rs b/fitnesstrax/app/src/components/text_entry.rs index 996f1ed..20a8cb2 100644 --- a/fitnesstrax/app/src/components/text_entry.rs +++ b/fitnesstrax/app/src/components/text_entry.rs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with Fit use gtk::prelude::*; use std::{cell::RefCell, rc::Rc}; +#[derive(Clone, Debug)] pub struct ParseError; #[derive(Clone)] diff --git a/fitnesstrax/app/src/components/weight.rs b/fitnesstrax/app/src/components/weight.rs index ddba3b5..48bbf7f 100644 --- a/fitnesstrax/app/src/components/weight.rs +++ b/fitnesstrax/app/src/components/weight.rs @@ -54,13 +54,25 @@ pub struct WeightEdit { } impl WeightEdit { - pub fn new(weight: Option>) -> Self { + pub fn new(weight: Option>, on_update: OnUpdate) -> Self + where + OnUpdate: Fn(si::Kilogram) + 'static, + { Self { entry: TextEntry::new( "0 kg", weight, |val: &si::Kilogram| val.to_string(), - |v: &str| v.parse::().map(|w| w * si::KG).map_err(|_| ParseError), + move |v: &str| { + let new_weight = v.parse::().map(|w| w * si::KG).map_err(|_| ParseError); + match new_weight { + Ok(w) => { + on_update(w); + Ok(w) + } + Err(err) => Err(err), + } + }, ), } } diff --git a/fitnesstrax/app/src/main.rs b/fitnesstrax/app/src/main.rs index 0811941..1e86d60 100644 --- a/fitnesstrax/app/src/main.rs +++ b/fitnesstrax/app/src/main.rs @@ -18,12 +18,12 @@ mod app; mod app_window; mod components; mod types; +mod view_models; mod views; use adw::prelude::*; use app_window::AppWindow; use std::{env, path::PathBuf}; -use types::DayInterval; const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev"; const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax"; diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs new file mode 100644 index 0000000..5e0ecbd --- /dev/null +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -0,0 +1,220 @@ +/* +Copyright 2024, Savanni D'Gerinel + +This file is part of FitnessTrax. + +FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU +General Public License as published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without +even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +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; +use dimensioned::si; +use emseries::{Record, RecordId, Recordable}; +use ft_core::TraxRecord; +use std::{ + collections::HashMap, + ops::Deref, + sync::{Arc, RwLock}, +}; + +#[derive(Clone, Debug)] +enum RecordState { + Original(Record), + New(T), + Updated(Record), + Deleted(Record), +} + +impl RecordState { + fn id(&self) -> Option<&RecordId> { + match self { + RecordState::Original(ref r) => Some(&r.id), + RecordState::New(ref r) => None, + RecordState::Updated(ref r) => Some(&r.id), + RecordState::Deleted(ref r) => Some(&r.id), + } + } + + fn with_value(self, value: T) -> RecordState { + match self { + RecordState::Original(r) => RecordState::Updated(Record { data: value, ..r }), + RecordState::New(_) => RecordState::New(value), + RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..r }), + RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..r }), + } + } + + fn with_delete(self) -> Option> { + match self { + RecordState::Original(r) => Some(RecordState::Deleted(r)), + RecordState::New(r) => None, + RecordState::Updated(r) => Some(RecordState::Deleted(r)), + RecordState::Deleted(r) => Some(RecordState::Deleted(r)), + } + } +} + +impl Deref for RecordState { + type Target = T; + fn deref(&self) -> &Self::Target { + match self { + RecordState::Original(ref r) => &r.data, + RecordState::New(ref r) => &r, + RecordState::Updated(ref r) => &r.data, + RecordState::Deleted(ref r) => &r.data, + } + } +} + +#[derive(Default)] +struct DayDetailViewModelInner {} + +#[derive(Clone, Default)] +pub struct DayDetailViewModel { + app: Option, + pub date: chrono::NaiveDate, + weight: Arc>>>, + steps: Arc>>>, + records: Arc>>>, +} + +impl DayDetailViewModel { + pub fn new(date: chrono::NaiveDate, records: Vec>, app: App) -> Self { + let (weight_records, records): (Vec>, Vec>) = + records.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), + date, + weight: Arc::new(RwLock::new( + weight_records + .first() + .and_then(|r| match r.data { + TraxRecord::Weight(ref w) => Some((r.id.clone(), w.clone())), + _ => None, + }) + .map(|(id, w)| RecordState::Original(Record { id, data: w })), + )), + steps: Arc::new(RwLock::new( + step_records + .first() + .and_then(|r| match r.data { + TraxRecord::Steps(ref w) => Some((r.id.clone(), w.clone())), + _ => None, + }) + .map(|(id, w)| RecordState::Original(Record { id, data: w })), + )), + + records: Arc::new(RwLock::new( + records + .into_iter() + .map(|r| (r.id.clone(), RecordState::Original(r))) + .collect::>>(), + )), + } + } + + pub fn weight(&self) -> Option> { + match *self.weight.read().unwrap() { + Some(ref w) => Some((*w).weight), + None => None, + } + } + + 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.clone(), + weight: new_weight, + }), + None => RecordState::New(ft_core::Weight { + date: self.date.clone(), + weight: new_weight, + }), + }; + *record = Some(new_record); + } + + pub fn steps(&self) -> Option { + match *self.steps.read().unwrap() { + Some(ref w) => Some((*w).count), + None => None, + } + } + + pub fn set_steps(&self, new_count: u32) { + let mut record = self.steps.write().unwrap(); + let new_record = match *record { + Some(ref rstate) => rstate.clone().with_value(ft_core::Steps { + date: self.date.clone(), + count: new_count, + }), + None => RecordState::New(ft_core::Steps { + date: self.date.clone(), + count: new_count, + }), + }; + *record = Some(new_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 => {} + } + + let records = s + .records + .write() + .unwrap() + .drain() + .map(|(_, record)| record) + .collect::>>(); + + 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!(), + } + } + } + } + }); + } + + pub fn revert(&self) { + unimplemented!(); + } +} diff --git a/fitnesstrax/app/src/view_models/mod.rs b/fitnesstrax/app/src/view_models/mod.rs new file mode 100644 index 0000000..4e41990 --- /dev/null +++ b/fitnesstrax/app/src/view_models/mod.rs @@ -0,0 +1,18 @@ +/* +Copyright 2024, Savanni D'Gerinel + +This file is part of FitnessTrax. + +FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU +General Public License as published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without +even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . +*/ + +mod day_detail; +pub use day_detail::DayDetailViewModel; diff --git a/fitnesstrax/app/src/views/day_detail_view.rs b/fitnesstrax/app/src/views/day_detail_view.rs index eab3611..0b992bb 100644 --- a/fitnesstrax/app/src/views/day_detail_view.rs +++ b/fitnesstrax/app/src/views/day_detail_view.rs @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with Fit use crate::{ app::App, components::{DayDetail, DayEdit, Singleton, SingletonImpl}, + view_models::DayDetailViewModel, }; use dimensioned::si; use emseries::{Record, RecordId}; @@ -25,74 +26,12 @@ use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; use std::{ cell::RefCell, + collections::HashMap, + ops::Deref, rc::Rc, sync::{Arc, RwLock}, }; -#[derive(Default)] -struct DayDetailViewModelInner { - records: Vec>, - updated_records: Vec>, - new_records: Vec, - deleted_records: Vec, -} - -#[derive(Clone, Default)] -pub struct DayDetailViewModel { - app: Option, - pub date: chrono::NaiveDate, - inner: Arc>, -} - -impl DayDetailViewModel { - pub fn new(date: chrono::NaiveDate, records: Vec>, app: App) -> Self { - Self { - app: Some(app), - date, - inner: Arc::new(RwLock::new(DayDetailViewModelInner { - records, - updated_records: vec![], - new_records: vec![], - deleted_records: vec![], - })), - } - } - - pub fn weight(&self) -> Option> { - self.inner - .read() - .unwrap() - .records - .iter() - .find_map(|record| match record { - Record { - data: ft_core::TraxRecord::Weight(record), - .. - } => Some(record.weight.clone()), - _ => None, - }) - } - - pub fn save(&self) { - glib::spawn_future({ - let s = self.clone(); - async move { - if let Some(app) = s.app { - let updated_records = { - let mut data = s.inner.write().unwrap(); - data.updated_records - .drain(..) - .collect::>>() - }; - for record in updated_records.into_iter() { - let _ = app.update_record(record.clone()).await; - } - } - } - }); - } -} - #[derive(Default)] pub struct DayDetailViewPrivate { container: Singleton, @@ -144,63 +83,4 @@ impl DayDetailView { move || s.view() })); } - - /* - fn on_put_record(&self) -> Box { - let s = self.clone(); - let app = self.imp().app.clone(); - Box::new(move |record| { - let s = s.clone(); - let app = app.clone(); - glib::spawn_future_local({ - async move { - match &*app.borrow() { - Some(app) => { - let id = app - .put_record(record.clone()) - .await - .expect("successful write"); - s.imp() - .records - .borrow_mut() - .push(Record { id, data: record }); - } - None => {} - } - s.view(); - } - }); - }) - } - - fn on_update_record(&self) -> Box)> { - let s = self.clone(); - let app = self.imp().app.clone(); - Box::new(move |updated_record| { - let app = app.clone(); - - let mut records = s.imp().records.borrow_mut(); - let idx = records.iter().position(|r| r.id == updated_record.id); - match idx { - Some(i) => records[i] = updated_record.clone(), - None => records.push(updated_record.clone()), - } - - glib::spawn_future_local({ - let s = s.clone(); - async move { - match &*app.borrow() { - Some(app) => { - let _ = app.update_record(updated_record).await; - } - None => { - println!("no app!"); - } - } - s.view(); - } - }); - }) - } - */ } diff --git a/fitnesstrax/app/src/views/mod.rs b/fitnesstrax/app/src/views/mod.rs index 4993459..9957823 100644 --- a/fitnesstrax/app/src/views/mod.rs +++ b/fitnesstrax/app/src/views/mod.rs @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with Fit use gtk::prelude::*; mod day_detail_view; -pub use day_detail_view::{DayDetailView, DayDetailViewModel}; +pub use day_detail_view::DayDetailView; mod historical_view; pub use historical_view::HistoricalView; diff --git a/fitnesstrax/core/src/types.rs b/fitnesstrax/core/src/types.rs index 401f4a8..f27fe18 100644 --- a/fitnesstrax/core/src/types.rs +++ b/fitnesstrax/core/src/types.rs @@ -22,6 +22,16 @@ pub struct Steps { pub count: u32, } +impl Recordable for Steps { + fn timestamp(&self) -> Timestamp { + Timestamp::Date(self.date.clone()) + } + + fn tags(&self) -> Vec { + vec![] + } +} + /// TimeDistance represents workouts characterized by a duration and a distance travelled. These /// sorts of workouts can occur many times a day, depending on how one records things. I might /// record a single 30-km workout if I go on a long-distanec ride. Or I might record multiple 5km @@ -54,6 +64,16 @@ pub struct Weight { pub weight: si::Kilogram, } +impl Recordable for Weight { + fn timestamp(&self) -> Timestamp { + Timestamp::Date(self.date.clone()) + } + + fn tags(&self) -> Vec { + vec![] + } +} + #[derive(Clone, Debug, PartialEq)] pub enum RecordType { BikeRide, @@ -96,6 +116,13 @@ impl TraxRecord { _ => false, } } + + pub fn is_steps(&self) -> bool { + match self { + TraxRecord::Steps(_) => true, + _ => false, + } + } } impl Recordable for TraxRecord { @@ -104,10 +131,10 @@ impl Recordable for TraxRecord { TraxRecord::BikeRide(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::Row(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::Run(rec) => Timestamp::DateTime(rec.datetime.clone()), - TraxRecord::Steps(rec) => Timestamp::Date(rec.date), + TraxRecord::Steps(rec) => rec.timestamp(), TraxRecord::Swim(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::Walk(rec) => Timestamp::DateTime(rec.datetime.clone()), - TraxRecord::Weight(rec) => Timestamp::Date(rec.date), + TraxRecord::Weight(rec) => rec.timestamp(), } }