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.
This commit is contained in:
Savanni D'Gerinel 2024-01-29 08:26:41 -05:00
parent 4fd377a3f1
commit 7d5d639ed9
7 changed files with 168 additions and 68 deletions

View File

@ -17,10 +17,7 @@ You should have received a copy of the GNU General Public License along with Fit
// use chrono::NaiveDate; // use chrono::NaiveDate;
// use ft_core::TraxRecord; // use ft_core::TraxRecord;
use crate::{ use crate::{
components::{ components::{steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, Weight},
steps_editor, text_entry::distance_field, time_distance_summary, weight_field, ActionGroup,
Steps, Weight,
},
view_models::DayDetailViewModel, view_models::DayDetailViewModel,
}; };
use emseries::{Record, RecordId}; use emseries::{Record, RecordId};
@ -419,7 +416,7 @@ where
biking_button.connect_clicked({ biking_button.connect_clicked({
let view_model = view_model.clone(); let view_model = view_model.clone();
move |_| { move |_| {
let workout = view_model.new_record(RecordType::Walk); let workout = view_model.new_record(RecordType::BikeRide);
add_row(workout); add_row(workout);
} }
}); });

View File

@ -27,9 +27,7 @@ mod steps;
pub use steps::{steps_editor, Steps}; pub use steps::{steps_editor, Steps};
mod text_entry; mod text_entry;
pub use text_entry::{ pub use text_entry::{distance_field, duration_field, time_field, weight_field, TextEntry};
distance_field, duration_field, time_field, weight_field, ParseError, TextEntry,
};
mod time_distance; mod time_distance;
pub use time_distance::{time_distance_detail, time_distance_summary}; pub use time_distance::{time_distance_detail, time_distance_summary};

View File

@ -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 <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::components::{text_entry::OnUpdate, ParseError, TextEntry}; use crate::{components::TextEntry, types::ParseError};
use gtk::prelude::*; use gtk::prelude::*;
#[derive(Default)] #[derive(Default)]

View File

@ -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 <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::types::{Distance, Duration, FormatOption, ParseError};
use dimensioned::si; use dimensioned::si;
use gtk::prelude::*; use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
#[derive(Clone, Debug)]
pub struct ParseError;
pub type Renderer<T> = dyn Fn(&T) -> String; pub type Renderer<T> = dyn Fn(&T) -> String;
pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>; pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
pub type OnUpdate<T> = dyn Fn(Option<T>); pub type OnUpdate<T> = dyn Fn(Option<T>);
@ -153,47 +151,28 @@ where
) )
} }
pub fn distance_field<OnUpdate>( pub fn distance_field<OnUpdate>(value: Option<Distance>, on_update: OnUpdate) -> TextEntry<Distance>
value: Option<si::Meter<f64>>,
on_update: OnUpdate,
) -> TextEntry<si::Meter<f64>>
where where
OnUpdate: Fn(Option<si::Meter<f64>>) + 'static, OnUpdate: Fn(Option<Distance>) + 'static,
{ {
TextEntry::new( TextEntry::new(
"0 km", "0 km",
value, value,
|v| format!("{} km", v.value_unsafe / 1000.), |v| format!("{} km", v.value_unsafe / 1000.),
|s| { Distance::parse,
let digits = take_digits(s.to_owned());
let value = digits.parse::<f64>().map_err(|_| ParseError)?;
println!("value: {}", value);
Ok(value * 1000. * si::M)
},
on_update, on_update,
) )
} }
pub fn duration_field<OnUpdate>( pub fn duration_field<OnUpdate>(value: Option<Duration>, on_update: OnUpdate) -> TextEntry<Duration>
value: Option<si::Second<f64>>,
on_update: OnUpdate,
) -> TextEntry<si::Second<f64>>
where where
OnUpdate: Fn(Option<si::Second<f64>>) + 'static, OnUpdate: Fn(Option<Duration>) + 'static,
{ {
TextEntry::new( TextEntry::new(
"0 minutes", "0 minutes",
value, value,
|v| format!("{} minutes", v.value_unsafe / 1000.), |v| v.format(FormatOption::Abbreviated),
|s| { |s| Duration::parse(s),
let digits = take_digits(s.to_owned());
let value = digits.parse::<f64>().map_err(|_| ParseError)?;
Ok(value * 60. * si::S)
},
on_update, on_update,
) )
} }
fn take_digits(s: String) -> String {
s.chars().take_while(|t| t.is_digit(10)).collect::<String>()
}

View File

@ -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 crate::components::{EditView, ParseError, TextEntry};
// use chrono::{Local, NaiveDate}; // use chrono::{Local, NaiveDate};
// use dimensioned::si; // 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 dimensioned::si;
use ft_core::{RecordType, TimeDistance}; use ft_core::{RecordType, TimeDistance};
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
pub fn time_distance_summary( pub fn time_distance_summary(distance: Distance, duration: Duration) -> Option<gtk::Label> {
distance: si::Meter<f64>, let text = match (*distance > si::M, *duration > si::S) {
duration: si::Second<f64>,
) -> Option<gtk::Label> {
let text = match (distance > si::M, duration > si::S) {
(true, true) => Some(format!( (true, true) => Some(format!(
"{} kilometers of biking in {} minutes", "{} of biking in {}",
distance.value_unsafe / 1000., distance.format(FormatOption::Full),
duration.value_unsafe / 60. duration.format(FormatOption::Full)
)), )),
(true, false) => Some(format!( (true, false) => Some(format!("{} of biking", distance.format(FormatOption::Full))),
"{} kilometers of biking", (false, true) => Some(format!("{} of biking", duration.format(FormatOption::Full))),
distance.value_unsafe / 1000.
)),
(false, true) => Some(format!("{} seconds of biking", duration.value_unsafe / 60.)),
(false, false) => None, (false, false) => None,
}; };
@ -72,7 +69,7 @@ pub fn time_distance_detail(type_: ft_core::RecordType, record: ft_core::TimeDis
.label( .label(
record record
.distance .distance
.map(|dist| format!("{}", dist)) .map(|dist| Distance::from(dist).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()), .unwrap_or("".to_owned()),
) )
.build(), .build(),
@ -84,7 +81,7 @@ pub fn time_distance_detail(type_: ft_core::RecordType, record: ft_core::TimeDis
.label( .label(
record record
.duration .duration
.map(|duration| format!("{}", duration)) .map(|duration| Duration::from(duration).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()), .unwrap_or("".to_owned()),
) )
.build(), .build(),
@ -173,16 +170,16 @@ impl TimeDistanceEdit {
.widget(), .widget(),
); );
details_row.append( details_row.append(
&distance_field(workout.distance, { &distance_field(workout.distance.map(Distance::from), {
let s = s.clone(); let s = s.clone();
move |d| s.update_distance(d) move |d| s.update_distance(d.map(|d| *d))
}) })
.widget(), .widget(),
); );
details_row.append( details_row.append(
&duration_field(workout.duration, { &duration_field(workout.duration.map(Duration::from), {
let s = s.clone(); let s = s.clone();
move |d| s.update_duration(d) move |d| s.update_duration(d.map(|d| *d))
}) })
.widget(), .widget(),
); );

View File

@ -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 // 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 // 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 { impl Default for DayInterval {
fn default() -> Self { fn default() -> Self {
Self { Self {
start: (Local::now() - Duration::days(7)).date_naive(), start: (Local::now() - chrono::Duration::days(7)).date_naive(),
end: Local::now().date_naive(), end: Local::now().date_naive(),
} }
} }
@ -38,10 +42,132 @@ impl Iterator for DayIterator {
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
if self.current <= self.end { if self.current <= self.end {
let val = self.current; let val = self.current;
self.current += Duration::days(1); self.current += chrono::Duration::days(1);
Some(val) Some(val)
} else { } else {
None 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<f64>,
}
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<Distance, ParseError> {
let digits = take_digits(s.to_owned());
let value = digits.parse::<f64>().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<f64>;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl From<si::Meter<f64>> for Distance {
fn from(value: si::Meter<f64>) -> Self {
Self { value }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct Duration {
value: si::Second<f64>,
}
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<Duration, ParseError> {
let digits = take_digits(s.to_owned());
let value = digits.parse::<f64>().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<f64>;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl From<si::Second<f64>> for Duration {
fn from(value: si::Second<f64>) -> Self {
Self { value }
}
}
fn take_digits(s: String) -> String {
s.chars().take_while(|t| t.is_digit(10)).collect::<String>()
}

View File

@ -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 <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::app::App; use crate::{
app::App,
types::{Distance, Duration},
};
use dimensioned::si; use dimensioned::si;
use emseries::{Record, RecordId, Recordable}; use emseries::{Record, RecordId, Recordable};
use ft_core::{RecordType, TimeDistance, TraxRecord}; use ft_core::{RecordType, TimeDistance, TraxRecord};
@ -164,9 +167,9 @@ impl DayDetailViewModel {
*record = Some(new_record); *record = Some(new_record);
} }
pub fn biking_summary(&self) -> (si::Meter<f64>, si::Second<f64>) { pub fn biking_summary(&self) -> (Distance, Duration) {
self.records.read().unwrap().iter().fold( self.records.read().unwrap().iter().fold(
(0. * si::M, 0. * si::S), (Distance::default(), Duration::default()),
|(acc_distance, acc_duration), (_, record)| match record.data() { |(acc_distance, acc_duration), (_, record)| match record.data() {
Some(Record { Some(Record {
data: data:
@ -176,11 +179,11 @@ impl DayDetailViewModel {
.. ..
}) => ( }) => (
distance distance
.map(|distance| acc_distance + distance) .map(|distance| acc_distance + Distance::from(distance))
.unwrap_or(acc_distance), .unwrap_or(acc_distance),
(duration duration
.map(|duration| acc_duration + duration) .map(|duration| acc_duration + Duration::from(duration))
.unwrap_or(acc_duration)), .unwrap_or(acc_duration),
), ),
_ => (acc_distance, acc_duration), _ => (acc_distance, acc_duration),