Compare commits

..

22 Commits

Author SHA1 Message Date
Savanni D'Gerinel 31d74588fb Clean up warnings and remove printlns 2024-01-31 09:42:07 -05:00
Savanni D'Gerinel 07f487b139 Reload data when the user saves on the DayEdit panel
This required some big overhauls. The view model no longer takes records. It only takes the date that it is responsible for, and it will ask the database for records pertaining to that date. This means that once the view model has saved all of its records, it can simply reload those records from the database. This has the effect that as soon as the user moves from DayEdit back to DayDetail, all of the interesting information has been repopulated.
2024-01-31 09:38:28 -05:00
Savanni D'Gerinel 79d705c1d0 The view model can no longer be initialized without an app 2024-01-31 09:38:28 -05:00
Savanni D'Gerinel 2d476f266c 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.
2024-01-31 09:38:28 -05:00
Savanni D'Gerinel 798fbff320 Show existing time/distance workout rows in day detail and editor 2024-01-31 09:28:22 -05:00
Savanni D'Gerinel 6e26923629 Save new time/distance records
This sets up a bunch of callbacks. We're starting to get into Callback Hell, where there are things that need knowledge that I really don't want them to have.

However, edit fields for TimeDistanceEdit now propogate data back into the view model, which is then able to save the results.
2024-01-31 09:28:22 -05:00
Savanni D'Gerinel 4fdf390ecf Build the facilities to add a new time/distance workout
This adds the code to show the new records in the UI, plus it adds them to the view model. Some of the representation changed in order to facilitate linking UI elements to particular records. There are now some buttons to create workouts of various types, clicking on a button adds a new row to the UI, and it also adds a new record to the view model. Saving the view model writes the records to the database.
2024-01-31 09:17:59 -05:00
Savanni D'Gerinel 29b1e6054b Make emseries::Record copyable 2024-01-31 09:16:35 -05:00
Savanni D'Gerinel 0f5af82cb5 Build some convenienc functions for measurement entry fields
Move the weight field into text_entry
2024-01-31 09:16:35 -05:00
Savanni D'Gerinel 525cc88c25 Add buttons with icons to represent workouts 2024-01-31 09:12:55 -05:00
Savanni D'Gerinel 06d118060e Add a test program for gnome icons 2024-01-31 09:12:55 -05:00
Savanni D'Gerinel 251077b0c1 Implement the Edit Cancel button 2024-01-31 09:12:55 -05:00
Savanni D'Gerinel ce8bed13f9 Render time distance details in the day detail view 2024-01-31 09:09:04 -05:00
Savanni D'Gerinel 279810f7d7 Show a summary of the day's biking stats when there is one 2024-01-31 09:09:04 -05:00
Savanni D'Gerinel 772188b470 Set up text entry fields for all of the common metrics 2024-01-31 08:56:54 -05:00
Savanni D'Gerinel bc31522c95 Add the on_update callback to TextEntry, and test the component 2024-01-31 08:44:46 -05:00
Savanni D'Gerinel 6c68564a77 Create a function which safely initializes GTK once
This is only available in test code, and it allows GUI component tests to run without having to worry about double-initializing GTK
2024-01-31 08:40:55 -05:00
Savanni D'Gerinel 55c1a6372f Rename the formatters 2024-01-31 08:38:17 -05:00
Savanni D'Gerinel 2cbd539bf4 Add a type for managing Time of Day values 2024-01-31 08:27:42 -05:00
Savanni D'Gerinel 7d14308def Create automated testing for weight, duration, and distance formatters 2024-01-31 08:10:46 -05:00
Savanni D'Gerinel dcd6301bb9 Introduce structures for formatting and parsing Duration and Distance values
These aren't in use yet.
2024-01-30 10:11:30 -05:00
Savanni D'Gerinel 69567db486 Introduce a structure for formatting and parsing Weight values 2024-01-30 10:08:10 -05:00
9 changed files with 403 additions and 126 deletions

View File

@ -17,7 +17,9 @@ 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::{steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, Weight}, components::{
steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, WeightLabel,
},
view_models::DayDetailViewModel, view_models::DayDetailViewModel,
}; };
use emseries::{Record, RecordId}; use emseries::{Record, RecordId};
@ -145,7 +147,7 @@ impl DayDetail {
let top_row = gtk::Box::builder() let top_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
.build(); .build();
let weight_view = Weight::new(view_model.weight()); let weight_view = WeightLabel::new(view_model.weight());
top_row.append(&weight_view.widget()); top_row.append(&weight_view.widget());
let steps_view = Steps::new(view_model.steps()); let steps_view = Steps::new(view_model.steps());
@ -339,7 +341,7 @@ fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box {
let view_model = view_model.clone(); let view_model = view_model.clone();
move |w| match w { move |w| match w {
Some(w) => view_model.set_weight(w), Some(w) => view_model.set_weight(w),
None => unimplemented!("need to delete the weight entry"), None => eprintln!("have not implemented record delete"),
} }
}) })
.widget(), .widget(),
@ -350,7 +352,7 @@ fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box {
let view_model = view_model.clone(); let view_model = view_model.clone();
move |s| match s { move |s| match s {
Some(s) => view_model.set_steps(s), Some(s) => view_model.set_steps(s),
None => unimplemented!("need to delete the steps entry"), None => eprintln!("have not implemented record delete"),
} }
}) })
.widget(), .widget(),

View File

@ -34,7 +34,7 @@ mod time_distance;
pub use time_distance::{time_distance_detail, time_distance_summary}; pub use time_distance::{time_distance_detail, time_distance_summary};
mod weight; mod weight;
pub use weight::Weight; pub use weight::WeightLabel;
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com> Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax. This file is part of FitnessTrax.
@ -14,8 +14,9 @@ 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 crate::types::{
use dimensioned::si; DistanceFormatter, DurationFormatter, FormatOption, ParseError, TimeFormatter, WeightFormatter,
};
use gtk::prelude::*; use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
@ -25,6 +26,7 @@ pub type OnUpdate<T> = dyn Fn(Option<T>);
#[derive(Clone)] #[derive(Clone)]
pub struct TextEntry<T: Clone + std::fmt::Debug> { pub struct TextEntry<T: Clone + std::fmt::Debug> {
value: Rc<RefCell<Option<T>>>, value: Rc<RefCell<Option<T>>>,
widget: gtk::Entry, widget: gtk::Entry,
parser: Rc<Parser<T>>, parser: Rc<Parser<T>>,
on_update: Rc<OnUpdate<T>>, on_update: Rc<OnUpdate<T>>,
@ -78,6 +80,7 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
if buffer.text().is_empty() { if buffer.text().is_empty() {
*self.value.borrow_mut() = None; *self.value.borrow_mut() = None;
self.widget.remove_css_class("error"); self.widget.remove_css_class("error");
(self.on_update)(None);
return; return;
} }
match (self.parser)(buffer.text().as_str()) { match (self.parser)(buffer.text().as_str()) {
@ -96,62 +99,144 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
pub fn widget(&self) -> gtk::Widget { pub fn widget(&self) -> gtk::Widget {
self.widget.clone().upcast::<gtk::Widget>() self.widget.clone().upcast::<gtk::Widget>()
} }
}
pub fn weight_field<OnUpdate>( #[cfg(test)]
weight: Option<si::Kilogram<f64>>, fn has_parse_error(&self) -> bool {
on_update: OnUpdate, self.widget.has_css_class("error")
) -> TextEntry<si::Kilogram<f64>> }
where
OnUpdate: Fn(Option<si::Kilogram<f64>>) + 'static,
{
TextEntry::new(
"0 kg",
weight,
|val: &si::Kilogram<f64>| val.to_string(),
move |v: &str| v.parse::<f64>().map(|w| w * si::KG).map_err(|_| ParseError),
on_update,
)
} }
pub fn time_field<OnUpdate>( pub fn time_field<OnUpdate>(
value: chrono::NaiveTime, value: Option<TimeFormatter>,
on_update: OnUpdate, on_update: OnUpdate,
) -> TextEntry<chrono::NaiveTime> ) -> TextEntry<TimeFormatter>
where where
OnUpdate: Fn(Option<chrono::NaiveTime>) + 'static, OnUpdate: Fn(Option<TimeFormatter>) + 'static,
{ {
TextEntry::new( TextEntry::new(
"hh:mm", "HH:MM",
Some(value), value,
|v| v.format("%H:%M").to_string(), |val| val.format(FormatOption::Abbreviated),
|s| chrono::NaiveTime::parse_from_str(s, "%H:%M").map_err(|_| ParseError), TimeFormatter::parse,
on_update, on_update,
) )
} }
pub fn distance_field<OnUpdate>(value: Option<Distance>, on_update: OnUpdate) -> TextEntry<Distance> pub fn distance_field<OnUpdate>(
value: Option<DistanceFormatter>,
on_update: OnUpdate,
) -> TextEntry<DistanceFormatter>
where where
OnUpdate: Fn(Option<Distance>) + 'static, OnUpdate: Fn(Option<DistanceFormatter>) + 'static,
{ {
TextEntry::new( TextEntry::new(
"0 km", "0 km",
value, value,
|v| format!("{} km", v.value_unsafe / 1000.), |val| val.format(FormatOption::Abbreviated),
Distance::parse, DistanceFormatter::parse,
on_update, on_update,
) )
} }
pub fn duration_field<OnUpdate>(value: Option<Duration>, on_update: OnUpdate) -> TextEntry<Duration> pub fn duration_field<OnUpdate>(
value: Option<DurationFormatter>,
on_update: OnUpdate,
) -> TextEntry<DurationFormatter>
where where
OnUpdate: Fn(Option<Duration>) + 'static, OnUpdate: Fn(Option<DurationFormatter>) + 'static,
{ {
TextEntry::new( TextEntry::new(
"0 minutes", "0 m",
value, value,
|v| v.format(FormatOption::Abbreviated), |val| val.format(FormatOption::Abbreviated),
Duration::parse, DurationFormatter::parse,
on_update, on_update,
) )
} }
pub fn weight_field<OnUpdate>(
weight: Option<WeightFormatter>,
on_update: OnUpdate,
) -> TextEntry<WeightFormatter>
where
OnUpdate: Fn(Option<WeightFormatter>) + 'static,
{
TextEntry::new(
"0 kg",
weight,
|val| val.format(FormatOption::Abbreviated),
WeightFormatter::parse,
on_update,
)
}
#[cfg(test)]
mod test {
use super::*;
use crate::gtk_init::gtk_init;
fn setup_u32_entry() -> (Rc<RefCell<Option<u32>>>, TextEntry<u32>) {
let current_value = Rc::new(RefCell::new(None));
let entry = TextEntry::new(
"step count",
None,
|steps| format!("{}", steps),
|v| v.parse::<u32>().map_err(|_| ParseError),
{
let current_value = current_value.clone();
move |v| *current_value.borrow_mut() = v
},
);
(current_value, entry)
}
#[test]
fn it_responds_to_field_changes() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("15");
assert_eq!(*current_value.borrow(), Some(15));
}
#[test]
fn it_preserves_last_value_in_parse_error() {
crate::gtk_init::gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("a5");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
}
#[test]
fn it_update_on_empty_strings() {
gtk_init();
let (current_value, entry) = setup_u32_entry();
let buffer = entry.widget.buffer();
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
buffer.set_text("1");
assert_eq!(*current_value.borrow(), Some(1));
buffer.set_text("1a");
assert_eq!(*current_value.borrow(), Some(1));
assert!(entry.has_parse_error());
buffer.set_text("");
assert_eq!(*current_value.borrow(), None);
assert!(!entry.has_parse_error());
}
}

View File

@ -19,7 +19,7 @@ You should have received a copy of the GNU General Public License along with Fit
// use dimensioned::si; // use dimensioned::si;
use crate::{ use crate::{
components::{distance_field, duration_field, time_field}, components::{distance_field, duration_field, time_field},
types::{Distance, Duration, FormatOption}, types::{DistanceFormatter, DurationFormatter, FormatOption, TimeFormatter},
}; };
use dimensioned::si; use dimensioned::si;
use ft_core::{RecordType, TimeDistance}; use ft_core::{RecordType, TimeDistance};
@ -27,7 +27,10 @@ use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell; use std::cell::RefCell;
pub fn time_distance_summary(distance: Distance, duration: Duration) -> Option<gtk::Label> { pub fn time_distance_summary(
distance: DistanceFormatter,
duration: DurationFormatter,
) -> Option<gtk::Label> {
let text = match (*distance > si::M, *duration > si::S) { let text = match (*distance > si::M, *duration > si::S) {
(true, true) => Some(format!( (true, true) => Some(format!(
"{} of biking in {}", "{} of biking in {}",
@ -69,7 +72,7 @@ pub fn time_distance_detail(type_: ft_core::RecordType, record: ft_core::TimeDis
.label( .label(
record record
.distance .distance
.map(|dist| Distance::from(dist).format(FormatOption::Abbreviated)) .map(|dist| DistanceFormatter::from(dist).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()), .unwrap_or("".to_owned()),
) )
.build(), .build(),
@ -81,7 +84,9 @@ pub fn time_distance_detail(type_: ft_core::RecordType, record: ft_core::TimeDis
.label( .label(
record record
.duration .duration
.map(|duration| Duration::from(duration).format(FormatOption::Abbreviated)) .map(|duration| {
DurationFormatter::from(duration).format(FormatOption::Abbreviated)
})
.unwrap_or("".to_owned()), .unwrap_or("".to_owned()),
) )
.build(), .build(),
@ -162,23 +167,26 @@ impl TimeDistanceEdit {
.build(); .build();
details_row.append( details_row.append(
&time_field(workout.datetime.naive_local().time(), { &time_field(
Some(TimeFormatter::from(workout.datetime.naive_local().time())),
{
let s = s.clone();
move |t| s.update_time(t)
},
)
.widget(),
);
details_row.append(
&distance_field(workout.distance.map(DistanceFormatter::from), {
let s = s.clone(); let s = s.clone();
move |t| s.update_time(t) move |d| s.update_distance(d)
}) })
.widget(), .widget(),
); );
details_row.append( details_row.append(
&distance_field(workout.distance.map(Distance::from), { &duration_field(workout.duration.map(DurationFormatter::from), {
let s = s.clone(); let s = s.clone();
move |d| s.update_distance(d.map(|d| *d)) move |d| s.update_duration(d)
})
.widget(),
);
details_row.append(
&duration_field(workout.duration.map(Duration::from), {
let s = s.clone();
move |d| s.update_duration(d.map(|d| *d))
}) })
.widget(), .widget(),
); );
@ -188,19 +196,19 @@ impl TimeDistanceEdit {
s s
} }
fn update_time(&self, _time: Option<chrono::NaiveTime>) { fn update_time(&self, time: Option<TimeFormatter>) {
unimplemented!() unimplemented!()
} }
fn update_distance(&self, distance: Option<si::Meter<f64>>) { fn update_distance(&self, distance: Option<DistanceFormatter>) {
let mut workout = self.imp().workout.borrow_mut(); let mut workout = self.imp().workout.borrow_mut();
workout.distance = distance; workout.distance = distance.map(|d| *d);
(self.imp().on_update.borrow())(self.imp().type_.borrow().clone(), workout.clone()); (self.imp().on_update.borrow())(self.imp().type_.borrow().clone(), workout.clone());
} }
fn update_duration(&self, duration: Option<si::Second<f64>>) { fn update_duration(&self, duration: Option<DurationFormatter>) {
let mut workout = self.imp().workout.borrow_mut(); let mut workout = self.imp().workout.borrow_mut();
workout.duration = duration; workout.duration = duration.map(|d| *d);
(self.imp().on_update.borrow())(self.imp().type_.borrow().clone(), workout.clone()); (self.imp().on_update.borrow())(self.imp().type_.borrow().clone(), workout.clone());
} }
} }

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com> Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax. This file is part of FitnessTrax.
@ -14,22 +14,26 @@ 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::TextEntry,
types::{FormatOption, WeightFormatter},
};
use dimensioned::si; use dimensioned::si;
use gtk::prelude::*; use gtk::prelude::*;
pub struct Weight { pub struct WeightLabel {
label: gtk::Label, label: gtk::Label,
} }
impl Weight { impl WeightLabel {
pub fn new(weight: Option<si::Kilogram<f64>>) -> Self { pub fn new(weight: Option<WeightFormatter>) -> Self {
let label = gtk::Label::builder() let label = gtk::Label::builder()
.css_classes(["card", "weight-view"]) .css_classes(["card", "weight-view"])
.can_focus(true) .can_focus(true)
.build(); .build();
match weight { 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"), None => label.set_text("No weight recorded"),
} }

View File

@ -0,0 +1,10 @@
use std::sync::Once;
static INITIALIZED: Once = Once::new();
pub fn gtk_init() {
INITIALIZED.call_once(|| {
eprintln!("initializing GTK");
let _ = gtk::init();
});
}

View File

@ -17,6 +17,8 @@ You should have received a copy of the GNU General Public License along with Fit
mod app; mod app;
mod app_window; mod app_window;
mod components; mod components;
#[cfg(test)]
mod gtk_init;
mod types; mod types;
mod view_models; mod view_models;
mod views; mod views;

View File

@ -1,7 +1,7 @@
use chrono::{Local, NaiveDate}; use chrono::{Local, NaiveDate};
use dimensioned::si; use dimensioned::si;
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
pub struct ParseError; 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
@ -56,66 +56,148 @@ pub enum FormatOption {
Full, Full,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Distance { pub struct TimeFormatter(chrono::NaiveTime);
value: si::Meter<f64>,
}
impl Distance { impl TimeFormatter {
pub fn format(&self, option: FormatOption) -> String { pub fn format(&self, option: FormatOption) -> String {
match option { match option {
FormatOption::Abbreviated => format!("{} km", self.value.value_unsafe / 1000.), FormatOption::Abbreviated => self.0.format("%H:%M"),
FormatOption::Full => format!("{} kilometers", self.value.value_unsafe / 1000.), FormatOption::Full => self.0.format("%H:%M:%S"),
}
.to_string()
}
pub fn parse(s: &str) -> Result<TimeFormatter, ParseError> {
let parts = s
.split(':')
.map(|part| part.parse::<u32>().map_err(|_| ParseError))
.collect::<Result<Vec<u32>, 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<chrono::NaiveTime> for TimeFormatter {
fn from(value: chrono::NaiveTime) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct WeightFormatter(si::Kilogram<f64>);
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<Distance, ParseError> { pub fn parse(s: &str) -> Result<WeightFormatter, ParseError> {
let digits = take_digits(s.to_owned()); s.parse::<f64>()
let value = digits.parse::<f64>().map_err(|_| ParseError)?; .map(|w| WeightFormatter(w * si::KG))
Ok(Distance { .map_err(|_| ParseError)
value: value * 1000. * si::M,
})
} }
} }
impl std::ops::Add for Distance { impl std::ops::Add for WeightFormatter {
type Output = Distance; type Output = WeightFormatter;
fn add(self, rside: Self) -> Self::Output { fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.value + rside.value) Self::Output::from(self.0 + rside.0)
} }
} }
impl std::ops::Sub for Distance { impl std::ops::Sub for WeightFormatter {
type Output = Distance; type Output = WeightFormatter;
fn sub(self, rside: Self) -> Self::Output { fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.value - rside.value) Self::Output::from(self.0 - rside.0)
} }
} }
impl std::ops::Deref for Distance { impl std::ops::Deref for WeightFormatter {
type Target = si::Meter<f64>; type Target = si::Kilogram<f64>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.value &self.0
} }
} }
impl From<si::Meter<f64>> for Distance { impl From<si::Kilogram<f64>> for WeightFormatter {
fn from(value: si::Meter<f64>) -> Self { fn from(value: si::Kilogram<f64>) -> Self {
Self { value } Self(value)
} }
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct Duration { pub struct DistanceFormatter(si::Meter<f64>);
value: si::Second<f64>,
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<DistanceFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DistanceFormatter(value * 1000. * si::M))
}
} }
impl Duration { 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<f64>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<si::Meter<f64>> for DistanceFormatter {
fn from(value: si::Meter<f64>) -> Self {
Self(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct DurationFormatter(si::Second<f64>);
impl DurationFormatter {
pub fn format(&self, option: FormatOption) -> String { pub fn format(&self, option: FormatOption) -> String {
let (hours, minutes) = self.hours_and_minutes(); let (hours, minutes) = self.hours_and_minutes();
let (h, m) = match option { let (h, m) = match option {
FormatOption::Abbreviated => ("h", "m"), FormatOption::Abbreviated => ("h", "m"),
FormatOption::Full => ("hours", "minutes"), FormatOption::Full => (" hours", " minutes"),
}; };
if hours > 0 { if hours > 0 {
format!("{}{} {}{}", hours, h, minutes, m) format!("{}{} {}{}", hours, h, minutes, m)
@ -124,51 +206,134 @@ impl Duration {
} }
} }
pub fn parse(s: &str) -> Result<Duration, ParseError> { pub fn parse(s: &str) -> Result<DurationFormatter, ParseError> {
let digits = take_digits(s.to_owned()); let value = s.parse::<f64>().map_err(|_| ParseError)?;
let value = digits.parse::<f64>().map_err(|_| ParseError)?; Ok(DurationFormatter(value * 60. * si::S))
Ok(Duration {
value: value * 60. * si::S,
})
} }
fn hours_and_minutes(&self) -> (i64, i64) { fn hours_and_minutes(&self) -> (i64, i64) {
let minutes: i64 = (self.value.value_unsafe / 60.).round() as i64; let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64;
let hours: i64 = minutes / 60; let hours: i64 = minutes / 60;
let minutes = minutes - (hours * 60); let minutes = minutes - (hours * 60);
(hours, minutes) (hours, minutes)
} }
} }
impl std::ops::Add for Duration { impl std::ops::Add for DurationFormatter {
type Output = Duration; type Output = DurationFormatter;
fn add(self, rside: Self) -> Self::Output { fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.value + rside.value) Self::Output::from(self.0 + rside.0)
} }
} }
impl std::ops::Sub for Duration { impl std::ops::Sub for DurationFormatter {
type Output = Duration; type Output = DurationFormatter;
fn sub(self, rside: Self) -> Self::Output { fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.value - rside.value) Self::Output::from(self.0 - rside.0)
} }
} }
impl std::ops::Deref for Duration { impl std::ops::Deref for DurationFormatter {
type Target = si::Second<f64>; type Target = si::Second<f64>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.value &self.0
} }
} }
impl From<si::Second<f64>> for Duration { impl From<si::Second<f64>> for DurationFormatter {
fn from(value: si::Second<f64>) -> Self { fn from(value: si::Second<f64>) -> Self {
Self { value } Self(value)
} }
} }
fn take_digits(s: String) -> String { #[cfg(test)]
s.chars() mod test {
.take_while(|t| t.is_ascii_digit()) use super::*;
.collect::<String>() 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());
}
} }

View File

@ -16,9 +16,8 @@ You should have received a copy of the GNU General Public License along with Fit
use crate::{ use crate::{
app::App, app::App,
types::{Distance, Duration}, types::{DistanceFormatter, DurationFormatter, WeightFormatter},
}; };
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};
use std::{ use std::{
@ -116,22 +115,24 @@ impl DayDetailViewModel {
s s
} }
pub fn weight(&self) -> Option<si::Kilogram<f64>> { pub fn weight(&self) -> Option<WeightFormatter> {
(*self.weight.read().unwrap()).as_ref().map(|w| w.weight) (*self.weight.read().unwrap())
.as_ref()
.map(|w| WeightFormatter::from(w.weight))
} }
pub fn set_weight(&self, new_weight: si::Kilogram<f64>) { pub fn set_weight(&self, new_weight: WeightFormatter) {
let mut record = self.weight.write().unwrap(); let mut record = self.weight.write().unwrap();
let new_record = match *record { let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight { Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
date: self.date, date: self.date,
weight: new_weight, weight: *new_weight,
}), }),
None => RecordState::New(Record { None => RecordState::New(Record {
id: RecordId::default(), id: RecordId::default(),
data: ft_core::Weight { data: ft_core::Weight {
date: self.date, date: self.date,
weight: new_weight, weight: *new_weight,
}, },
}), }),
}; };
@ -160,9 +161,9 @@ impl DayDetailViewModel {
*record = Some(new_record); *record = Some(new_record);
} }
pub fn biking_summary(&self) -> (Distance, Duration) { pub fn biking_summary(&self) -> (DistanceFormatter, DurationFormatter) {
self.records.read().unwrap().iter().fold( self.records.read().unwrap().iter().fold(
(Distance::default(), Duration::default()), (DistanceFormatter::default(), DurationFormatter::default()),
|(acc_distance, acc_duration), (_, record)| match record.data() { |(acc_distance, acc_duration), (_, record)| match record.data() {
Some(Record { Some(Record {
data: data:
@ -172,10 +173,10 @@ impl DayDetailViewModel {
.. ..
}) => ( }) => (
distance distance
.map(|distance| acc_distance + Distance::from(distance)) .map(|distance| acc_distance + DistanceFormatter::from(distance))
.unwrap_or(acc_distance), .unwrap_or(acc_distance),
duration duration
.map(|duration| acc_duration + Duration::from(duration)) .map(|duration| acc_duration + DurationFormatter::from(duration))
.unwrap_or(acc_duration), .unwrap_or(acc_duration),
), ),