diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index b4b130d..4e92180 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -17,7 +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, weight_editor, ActionGroup, Steps, Weight}, + components::{steps_editor, weight_editor, ActionGroup, Steps, WeightLabel}, view_models::DayDetailViewModel, }; use glib::Object; @@ -93,7 +93,7 @@ impl DaySummary { .css_classes(["day-summary__weight"]) .build(); if let Some(s) = view_model.steps() { - label.set_label(&format!("{} steps", s.to_string())); + label.set_label(&format!("{} steps", s)); } row.append(&label); @@ -162,7 +162,7 @@ impl DayDetail { let top_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .build(); - let weight_view = Weight::new(view_model.weight()); + let weight_view = WeightLabel::new(view_model.weight()); top_row.append(&weight_view.widget()); let steps_view = Steps::new(view_model.steps()); diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index 880664e..8b2fff5 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -27,13 +27,13 @@ mod steps; pub use steps::{steps_editor, Steps}; mod text_entry; -pub use text_entry::{ParseError, TextEntry}; +pub use text_entry::TextEntry; mod time_distance; pub use time_distance::TimeDistanceView; mod weight; -pub use weight::{weight_editor, Weight}; +pub use weight::{weight_editor, WeightLabel}; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; diff --git a/fitnesstrax/app/src/components/steps.rs b/fitnesstrax/app/src/components/steps.rs index e81c840..3860ca9 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::{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 12c0a6c..53702cb 100644 --- a/fitnesstrax/app/src/components/text_entry.rs +++ b/fitnesstrax/app/src/components/text_entry.rs @@ -1,5 +1,5 @@ /* -Copyright 2023, Savanni D'Gerinel +Copyright 2023-2024, Savanni D'Gerinel This file is part of FitnessTrax. @@ -14,12 +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::types::ParseError; use gtk::prelude::*; use std::{cell::RefCell, rc::Rc}; -#[derive(Clone, Debug)] -pub struct ParseError; - type Renderer = dyn Fn(&T) -> String; type Parser = dyn Fn(&str) -> Result; diff --git a/fitnesstrax/app/src/components/weight.rs b/fitnesstrax/app/src/components/weight.rs index 059e903..2afc259 100644 --- a/fitnesstrax/app/src/components/weight.rs +++ b/fitnesstrax/app/src/components/weight.rs @@ -1,5 +1,5 @@ /* -Copyright 2023, Savanni D'Gerinel +Copyright 2023-2024, Savanni D'Gerinel This file is part of FitnessTrax. @@ -14,23 +14,25 @@ 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::{ParseError, TextEntry}; -use dimensioned::si; +use crate::{ + components::TextEntry, + types::{FormatOption, WeightFormatter}, +}; use gtk::prelude::*; -pub struct Weight { +pub struct WeightLabel { label: gtk::Label, } -impl Weight { - pub fn new(weight: Option>) -> Self { +impl WeightLabel { + pub fn new(weight: Option) -> Self { let label = gtk::Label::builder() .css_classes(["card", "weight-view"]) .can_focus(true) .build(); match weight { - Some(w) => label.set_text(&format!("{:?}", w)), + Some(w) => label.set_text(&w.format(FormatOption::Abbreviated)), None => label.set_text("No weight recorded"), } @@ -43,18 +45,18 @@ impl Weight { } pub fn weight_editor( - weight: Option>, + weight: Option, on_update: OnUpdate, -) -> TextEntry> +) -> TextEntry where - OnUpdate: Fn(si::Kilogram) + 'static, + OnUpdate: Fn(WeightFormatter) + 'static, { TextEntry::new( "0 kg", weight, - |val: &si::Kilogram| val.to_string(), + |val: &WeightFormatter| val.format(FormatOption::Abbreviated), move |v: &str| { - let new_weight = v.parse::().map(|w| w * si::KG).map_err(|_| ParseError); + let new_weight = WeightFormatter::parse(v); match new_weight { Ok(w) => { on_update(w); diff --git a/fitnesstrax/app/src/types.rs b/fitnesstrax/app/src/types.rs index 799f2d8..0411596 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, PartialEq)] +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,306 @@ 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, PartialEq, Eq)] +pub struct TimeFormatter(chrono::NaiveTime); + +impl TimeFormatter { + fn format(&self, option: FormatOption) -> String { + match option { + FormatOption::Abbreviated => self.0.format("%H:%M"), + FormatOption::Full => self.0.format("%H:%M:%S"), + } + .to_string() + } + + fn parse(s: &str) -> Result { + let parts = s + .split(':') + .map(|part| part.parse::().map_err(|_| ParseError)) + .collect::, ParseError>>()?; + match parts.len() { + 0 => Err(ParseError), + 1 => Err(ParseError), + 2 => Ok(TimeFormatter( + chrono::NaiveTime::from_hms_opt(parts[0], parts[1], 0).unwrap(), + )), + 3 => Ok(TimeFormatter( + chrono::NaiveTime::from_hms_opt(parts[0], parts[1], parts[2]).unwrap(), + )), + _ => Err(ParseError), + } + } +} + +impl std::ops::Deref for TimeFormatter { + type Target = chrono::NaiveTime; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for TimeFormatter { + fn from(value: chrono::NaiveTime) -> Self { + Self(value) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)] +pub struct WeightFormatter(si::Kilogram); + +impl WeightFormatter { + pub fn format(&self, option: FormatOption) -> String { + match option { + FormatOption::Abbreviated => format!("{} kg", self.0.value_unsafe), + FormatOption::Full => format!("{} kilograms", self.0.value_unsafe), + } + } + + pub fn parse(s: &str) -> Result { + s.parse::() + .map(|w| WeightFormatter(w * si::KG)) + .map_err(|_| ParseError) + } +} + +impl std::ops::Add for WeightFormatter { + type Output = WeightFormatter; + fn add(self, rside: Self) -> Self::Output { + Self::Output::from(self.0 + rside.0) + } +} + +impl std::ops::Sub for WeightFormatter { + type Output = WeightFormatter; + fn sub(self, rside: Self) -> Self::Output { + Self::Output::from(self.0 - rside.0) + } +} + +impl std::ops::Deref for WeightFormatter { + type Target = si::Kilogram; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From> for WeightFormatter { + fn from(value: si::Kilogram) -> Self { + Self(value) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)] +pub struct DistanceFormatter(si::Meter); + +impl DistanceFormatter { + pub fn format(&self, option: FormatOption) -> String { + match option { + FormatOption::Abbreviated => format!("{} km", self.0.value_unsafe / 1000.), + FormatOption::Full => format!("{} kilometers", self.0.value_unsafe / 1000.), + } + } + + pub fn parse(s: &str) -> Result { + let value = s.parse::().map_err(|_| ParseError)?; + Ok(DistanceFormatter(value * 1000. * si::M)) + } +} + +impl std::ops::Add for DistanceFormatter { + type Output = DistanceFormatter; + fn add(self, rside: Self) -> Self::Output { + Self::Output::from(self.0 + rside.0) + } +} + +impl std::ops::Sub for DistanceFormatter { + type Output = DistanceFormatter; + fn sub(self, rside: Self) -> Self::Output { + Self::Output::from(self.0 - rside.0) + } +} + +impl std::ops::Deref for DistanceFormatter { + type Target = si::Meter; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From> for DistanceFormatter { + fn from(value: si::Meter) -> Self { + Self(value) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)] +pub struct DurationFormatter(si::Second); + +impl DurationFormatter { + 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 value = s.parse::().map_err(|_| ParseError)?; + Ok(DurationFormatter(value * 60. * si::S)) + } + + fn hours_and_minutes(&self) -> (i64, i64) { + let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64; + let hours: i64 = minutes / 60; + let minutes = minutes - (hours * 60); + (hours, minutes) + } +} + +impl std::ops::Add for DurationFormatter { + type Output = DurationFormatter; + fn add(self, rside: Self) -> Self::Output { + Self::Output::from(self.0 + rside.0) + } +} + +impl std::ops::Sub for DurationFormatter { + type Output = DurationFormatter; + fn sub(self, rside: Self) -> Self::Output { + Self::Output::from(self.0 - rside.0) + } +} + +impl std::ops::Deref for DurationFormatter { + type Target = si::Second; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From> for DurationFormatter { + fn from(value: si::Second) -> Self { + Self(value) + } +} + +/* +fn take_digits(s: String) -> String { + s.chars() + .take_while(|t| t.is_ascii_digit()) + .collect::() +} +*/ + +#[cfg(test)] +mod test { + use super::*; + use dimensioned::si; + + #[test] + fn it_parses_weight_values() { + assert_eq!( + WeightFormatter::parse("15.3"), + Ok(WeightFormatter(15.3 * si::KG)) + ); + assert_eq!(WeightFormatter::parse("15.ab"), Err(ParseError)); + } + + #[test] + fn it_formats_weight_values() { + assert_eq!( + WeightFormatter::from(15.3 * si::KG).format(FormatOption::Abbreviated), + "15.3 kg" + ); + assert_eq!( + WeightFormatter::from(15.3 * si::KG).format(FormatOption::Full), + "15.3 kilograms" + ); + } + + #[test] + fn it_parses_distance_values() { + assert_eq!( + DistanceFormatter::parse("70"), + Ok(DistanceFormatter(70000. * si::M)) + ); + assert_eq!(DistanceFormatter::parse("15.ab"), Err(ParseError)); + } + + #[test] + fn it_formats_distance_values() { + assert_eq!( + DistanceFormatter::from(70000. * si::M).format(FormatOption::Abbreviated), + "70 km" + ); + assert_eq!( + DistanceFormatter::from(70000. * si::M).format(FormatOption::Full), + "70 kilometers" + ); + } + + #[test] + fn it_parses_duration_values() { + assert_eq!( + DurationFormatter::parse("70"), + Ok(DurationFormatter(4200. * si::S)) + ); + assert_eq!(DurationFormatter::parse("15.ab"), Err(ParseError)); + } + + #[test] + fn it_formats_duration_values() { + assert_eq!( + DurationFormatter::from(4200. * si::S).format(FormatOption::Abbreviated), + "1h 10m" + ); + assert_eq!( + DurationFormatter::from(4200. * si::S).format(FormatOption::Full), + "1 hours 10 minutes" + ); + } + + #[test] + fn it_parses_time_values() { + assert_eq!( + TimeFormatter::parse("13:25"), + Ok(TimeFormatter::from( + chrono::NaiveTime::from_hms_opt(13, 25, 0).unwrap() + )), + ); + assert_eq!( + TimeFormatter::parse("13:25:50"), + Ok(TimeFormatter::from( + chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap() + )), + ); + } + + #[test] + fn it_formats_time_values() { + let time = TimeFormatter::from(chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap()); + assert_eq!(time.format(FormatOption::Abbreviated), "13:25".to_owned()); + assert_eq!(time.format(FormatOption::Full), "13:25:50".to_owned()); + } +} diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index f32e75f..cf8ac0e 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,8 +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::app::App; -use dimensioned::si; +use crate::{app::App, types::WeightFormatter}; use emseries::{Record, RecordId, Recordable}; use ft_core::TraxRecord; use std::{ @@ -125,20 +124,22 @@ impl DayDetailViewModel { } } - pub fn weight(&self) -> Option> { - (*self.weight.read().unwrap()).as_ref().map(|w| w.weight) + pub fn weight(&self) -> Option { + (*self.weight.read().unwrap()) + .as_ref() + .map(|w| WeightFormatter::from(w.weight)) } - pub fn set_weight(&self, new_weight: si::Kilogram) { + pub fn set_weight(&self, new_weight: WeightFormatter) { 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);