From 7d5d639ed92d06bbab3edce23776bf7499a93fcd Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Mon, 29 Jan 2024 08:26:41 -0500 Subject: [PATCH] Create Duration and Distance structures to handle rendering These structures handle parsing and rendering of a Duration and a Distance, allowing that knowledge to be centralized and reused. Then I'm using those structures in a variety of places in order to ensure that the information gets rendered consistently. --- fitnesstrax/app/src/components/day.rs | 7 +- fitnesstrax/app/src/components/mod.rs | 4 +- fitnesstrax/app/src/components/steps.rs | 2 +- fitnesstrax/app/src/components/text_entry.rs | 37 ++--- .../app/src/components/time_distance.rs | 37 +++-- fitnesstrax/app/src/types.rs | 132 +++++++++++++++++- fitnesstrax/app/src/view_models/day_detail.rs | 17 ++- 7 files changed, 168 insertions(+), 68 deletions(-) diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 3143302..e78d829 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -17,10 +17,7 @@ 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::{ - steps_editor, text_entry::distance_field, time_distance_summary, weight_field, ActionGroup, - Steps, Weight, - }, + components::{steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, Weight}, view_models::DayDetailViewModel, }; use emseries::{Record, RecordId}; @@ -419,7 +416,7 @@ where biking_button.connect_clicked({ let view_model = view_model.clone(); move |_| { - let workout = view_model.new_record(RecordType::Walk); + let workout = view_model.new_record(RecordType::BikeRide); add_row(workout); } }); diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index 3a5c8c1..cfa64f4 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -27,9 +27,7 @@ mod steps; pub use steps::{steps_editor, Steps}; mod text_entry; -pub use text_entry::{ - distance_field, duration_field, time_field, weight_field, ParseError, TextEntry, -}; +pub use text_entry::{distance_field, duration_field, time_field, weight_field, TextEntry}; mod time_distance; pub use time_distance::{time_distance_detail, time_distance_summary}; diff --git a/fitnesstrax/app/src/components/steps.rs b/fitnesstrax/app/src/components/steps.rs index f86da50..11ba591 100644 --- a/fitnesstrax/app/src/components/steps.rs +++ b/fitnesstrax/app/src/components/steps.rs @@ -14,7 +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 crate::components::{text_entry::OnUpdate, ParseError, TextEntry}; +use crate::{components::TextEntry, types::ParseError}; use gtk::prelude::*; #[derive(Default)] diff --git a/fitnesstrax/app/src/components/text_entry.rs b/fitnesstrax/app/src/components/text_entry.rs index b014b33..91cafb8 100644 --- a/fitnesstrax/app/src/components/text_entry.rs +++ b/fitnesstrax/app/src/components/text_entry.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::types::{Distance, Duration, FormatOption, ParseError}; use dimensioned::si; use gtk::prelude::*; use std::{cell::RefCell, rc::Rc}; -#[derive(Clone, Debug)] -pub struct ParseError; - pub type Renderer = dyn Fn(&T) -> String; pub type Parser = dyn Fn(&str) -> Result; pub type OnUpdate = dyn Fn(Option); @@ -153,47 +151,28 @@ where ) } -pub fn distance_field( - value: Option>, - on_update: OnUpdate, -) -> TextEntry> +pub fn distance_field(value: Option, on_update: OnUpdate) -> TextEntry where - OnUpdate: Fn(Option>) + 'static, + OnUpdate: Fn(Option) + 'static, { TextEntry::new( "0 km", value, |v| format!("{} km", v.value_unsafe / 1000.), - |s| { - let digits = take_digits(s.to_owned()); - let value = digits.parse::().map_err(|_| ParseError)?; - println!("value: {}", value); - Ok(value * 1000. * si::M) - }, + Distance::parse, on_update, ) } -pub fn duration_field( - value: Option>, - on_update: OnUpdate, -) -> TextEntry> +pub fn duration_field(value: Option, on_update: OnUpdate) -> TextEntry where - OnUpdate: Fn(Option>) + 'static, + OnUpdate: Fn(Option) + 'static, { TextEntry::new( "0 minutes", value, - |v| format!("{} minutes", v.value_unsafe / 1000.), - |s| { - let digits = take_digits(s.to_owned()); - let value = digits.parse::().map_err(|_| ParseError)?; - Ok(value * 60. * si::S) - }, + |v| v.format(FormatOption::Abbreviated), + |s| Duration::parse(s), on_update, ) } - -fn take_digits(s: String) -> String { - s.chars().take_while(|t| t.is_digit(10)).collect::() -} diff --git a/fitnesstrax/app/src/components/time_distance.rs b/fitnesstrax/app/src/components/time_distance.rs index f164540..0467ad0 100644 --- a/fitnesstrax/app/src/components/time_distance.rs +++ b/fitnesstrax/app/src/components/time_distance.rs @@ -17,28 +17,25 @@ You should have received a copy of the GNU General Public License along with Fit // use crate::components::{EditView, ParseError, TextEntry}; // use chrono::{Local, NaiveDate}; // use dimensioned::si; -use crate::components::{distance_field, duration_field, time_field}; +use crate::{ + components::{distance_field, duration_field, time_field}, + types::{Distance, Duration, FormatOption}, +}; use dimensioned::si; use ft_core::{RecordType, TimeDistance}; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; use std::{cell::RefCell, rc::Rc}; -pub fn time_distance_summary( - distance: si::Meter, - duration: si::Second, -) -> Option { - let text = match (distance > si::M, duration > si::S) { +pub fn time_distance_summary(distance: Distance, duration: Duration) -> Option { + let text = match (*distance > si::M, *duration > si::S) { (true, true) => Some(format!( - "{} kilometers of biking in {} minutes", - distance.value_unsafe / 1000., - duration.value_unsafe / 60. + "{} of biking in {}", + distance.format(FormatOption::Full), + duration.format(FormatOption::Full) )), - (true, false) => Some(format!( - "{} kilometers of biking", - distance.value_unsafe / 1000. - )), - (false, true) => Some(format!("{} seconds of biking", duration.value_unsafe / 60.)), + (true, false) => Some(format!("{} of biking", distance.format(FormatOption::Full))), + (false, true) => Some(format!("{} of biking", duration.format(FormatOption::Full))), (false, false) => None, }; @@ -72,7 +69,7 @@ pub fn time_distance_detail(type_: ft_core::RecordType, record: ft_core::TimeDis .label( record .distance - .map(|dist| format!("{}", dist)) + .map(|dist| Distance::from(dist).format(FormatOption::Abbreviated)) .unwrap_or("".to_owned()), ) .build(), @@ -84,7 +81,7 @@ pub fn time_distance_detail(type_: ft_core::RecordType, record: ft_core::TimeDis .label( record .duration - .map(|duration| format!("{}", duration)) + .map(|duration| Duration::from(duration).format(FormatOption::Abbreviated)) .unwrap_or("".to_owned()), ) .build(), @@ -173,16 +170,16 @@ impl TimeDistanceEdit { .widget(), ); details_row.append( - &distance_field(workout.distance, { + &distance_field(workout.distance.map(Distance::from), { let s = s.clone(); - move |d| s.update_distance(d) + move |d| s.update_distance(d.map(|d| *d)) }) .widget(), ); details_row.append( - &duration_field(workout.duration, { + &duration_field(workout.duration.map(Duration::from), { let s = s.clone(); - move |d| s.update_duration(d) + move |d| s.update_duration(d.map(|d| *d)) }) .widget(), ); diff --git a/fitnesstrax/app/src/types.rs b/fitnesstrax/app/src/types.rs index 799f2d8..f48cddc 100644 --- a/fitnesstrax/app/src/types.rs +++ b/fitnesstrax/app/src/types.rs @@ -1,4 +1,8 @@ -use chrono::{Duration, Local, NaiveDate}; +use chrono::{Local, NaiveDate}; +use dimensioned::si; + +#[derive(Clone, Debug)] +pub struct ParseError; // This interval doesn't feel right, either. The idea that I have a specific interval type for just // NaiveDate is odd. This should be genericized, as should the iterator. Also, it shouldn't live @@ -12,7 +16,7 @@ pub struct DayInterval { impl Default for DayInterval { fn default() -> Self { Self { - start: (Local::now() - Duration::days(7)).date_naive(), + start: (Local::now() - chrono::Duration::days(7)).date_naive(), end: Local::now().date_naive(), } } @@ -38,10 +42,132 @@ impl Iterator for DayIterator { fn next(&mut self) -> Option { if self.current <= self.end { let val = self.current; - self.current += Duration::days(1); + self.current += chrono::Duration::days(1); Some(val) } else { None } } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FormatOption { + Abbreviated, + Full, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)] +pub struct Distance { + value: si::Meter, +} + +impl Distance { + pub fn format(&self, option: FormatOption) -> String { + match option { + FormatOption::Abbreviated => format!("{} km", self.value.value_unsafe / 1000.), + FormatOption::Full => format!("{} kilometers", self.value.value_unsafe / 1000.), + } + } + + pub fn parse(s: &str) -> Result { + let digits = take_digits(s.to_owned()); + let value = digits.parse::().map_err(|_| ParseError)?; + println!("value: {}", value); + Ok(Distance { + value: value * 1000. * si::M, + }) + } +} + +impl std::ops::Add for Distance { + type Output = Distance; + fn add(self, rside: Self) -> Self::Output { + Self::Output::from(self.value + rside.value) + } +} + +impl std::ops::Sub for Distance { + type Output = Distance; + fn sub(self, rside: Self) -> Self::Output { + Self::Output::from(self.value - rside.value) + } +} + +impl std::ops::Deref for Distance { + type Target = si::Meter; + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl From> for Distance { + fn from(value: si::Meter) -> Self { + Self { value } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)] +pub struct Duration { + value: si::Second, +} + +impl Duration { + pub fn format(&self, option: FormatOption) -> String { + let (hours, minutes) = self.hours_and_minutes(); + let (h, m) = match option { + FormatOption::Abbreviated => ("h", "m"), + FormatOption::Full => ("hours", "minutes"), + }; + if hours > 0 { + format!("{}{} {}{}", hours, h, minutes, m) + } else { + format!("{}{}", minutes, m) + } + } + + pub fn parse(s: &str) -> Result { + let digits = take_digits(s.to_owned()); + let value = digits.parse::().map_err(|_| ParseError)?; + Ok(Duration { + value: value * 60. * si::S, + }) + } + + fn hours_and_minutes(&self) -> (i64, i64) { + let minutes: i64 = (self.value.value_unsafe / 60.).round() as i64; + let hours: i64 = (minutes / 60) as i64; + let minutes = minutes - (hours * 60); + (hours, minutes) + } +} + +impl std::ops::Add for Duration { + type Output = Duration; + fn add(self, rside: Self) -> Self::Output { + Self::Output::from(self.value + rside.value) + } +} + +impl std::ops::Sub for Duration { + type Output = Duration; + fn sub(self, rside: Self) -> Self::Output { + Self::Output::from(self.value - rside.value) + } +} + +impl std::ops::Deref for Duration { + type Target = si::Second; + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl From> for Duration { + fn from(value: si::Second) -> Self { + Self { value } + } +} + +fn take_digits(s: String) -> String { + s.chars().take_while(|t| t.is_digit(10)).collect::() +} diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index 9681ba6..fe1bd1d 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,7 +14,10 @@ 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 crate::{ + app::App, + types::{Distance, Duration}, +}; use dimensioned::si; use emseries::{Record, RecordId, Recordable}; use ft_core::{RecordType, TimeDistance, TraxRecord}; @@ -164,9 +167,9 @@ impl DayDetailViewModel { *record = Some(new_record); } - pub fn biking_summary(&self) -> (si::Meter, si::Second) { + pub fn biking_summary(&self) -> (Distance, Duration) { self.records.read().unwrap().iter().fold( - (0. * si::M, 0. * si::S), + (Distance::default(), Duration::default()), |(acc_distance, acc_duration), (_, record)| match record.data() { Some(Record { data: @@ -176,11 +179,11 @@ impl DayDetailViewModel { .. }) => ( distance - .map(|distance| acc_distance + distance) + .map(|distance| acc_distance + Distance::from(distance)) .unwrap_or(acc_distance), - (duration - .map(|duration| acc_duration + duration) - .unwrap_or(acc_duration)), + duration + .map(|duration| acc_duration + Duration::from(duration)) + .unwrap_or(acc_duration), ), _ => (acc_distance, acc_duration),