diff --git a/fitnesstrax/app/src/components/date_field.rs b/fitnesstrax/app/src/components/date_field.rs new file mode 100644 index 0000000..52ffccf --- /dev/null +++ b/fitnesstrax/app/src/components/date_field.rs @@ -0,0 +1,174 @@ +/* +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::{components::{i32_field_builder, TextEntry, month_field_builder}, types::ParseError}; +use chrono::{Datelike, Local}; +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; +use std::{cell::RefCell, rc::Rc}; + +pub struct DateFieldPrivate { + date: Rc>, + year: TextEntry, + month: TextEntry, + day: TextEntry, +} + +#[glib::object_subclass] +impl ObjectSubclass for DateFieldPrivate { + const NAME: &'static str = "DateField"; + type Type = DateField; + type ParentType = gtk::Box; + + fn new() -> Self { + let date = Rc::new(RefCell::new(Local::now().date_naive())); + + let year = i32_field_builder() + .with_value(date.borrow().year()) + .with_on_update( + { + let date = date.clone(); + move |value| { + if let Some(year) = value { + let mut date = date.borrow_mut(); + if let Some(new_date) = date.with_year(year) { + *date = new_date; + } + } + } + }) + .with_length(4) + .with_css_classes(vec!["date-field__year".to_owned()]).build(); + + let month = month_field_builder() + .with_value(date.borrow().month()) + .with_on_update( + { + let date = date.clone(); + move |value| { + if let Some(month) = value { + let mut date = date.borrow_mut(); + if let Some(new_date) = date.with_month(month) { + *date = new_date; + } + } + } + }) + .with_css_classes(vec!["date-field__month".to_owned()]) + .build(); + + /* Modify this so that it enforces the number of days per month */ + let day = TextEntry::builder() + .with_placeholder("day".to_owned()) + .with_value(date.borrow().day()) + .with_renderer(|v| format!("{}", v)) + .with_parser(|v| v.parse::().map_err(|_| ParseError)) + .with_on_update({ + let date = date.clone(); + move |value| { + if let Some(day) = value { + let mut date = date.borrow_mut(); + if let Some(new_date) = date.with_day(day) { + *date = new_date; + } + } + } + }) + .with_css_classes(vec!["date-field__day".to_owned()]) + .build(); + + Self { + date, + year, + month, + day, + } + } + +} + +impl ObjectImpl for DateFieldPrivate {} +impl WidgetImpl for DateFieldPrivate {} +impl BoxImpl for DateFieldPrivate {} + +glib::wrapper! { + pub struct DateField(ObjectSubclass) @extends gtk::Box, gtk::Widget; +} + +/* Render a date in the format 2006 Jan 01. The entire date is editable. When the user moves to one part of the date, it will be erased and replaced with a grey placeholder. + */ +impl DateField { + pub fn new(date: chrono::NaiveDate) -> Self { + let s: Self = Object::builder().build(); + + println!("{}", date); + + s.append(&s.imp().year.widget()); + s.append(&s.imp().month.widget()); + s.append(&s.imp().day.widget()); + + s.set_date(date); + + s + } + + pub fn set_date(&self, date: chrono::NaiveDate) { + self.imp().year.set_value(Some(date.year())); + self.imp().month.set_value(Some(date.month())); + self.imp().day.set_value(Some(date.day())); + + *self.imp().date.borrow_mut() = date; + } + + pub fn date(&self) -> chrono::NaiveDate { + *self.imp().date.borrow() + } + /* + pub fn is_valid(&self) -> bool { + false + } + */ +} + +#[cfg(test)] +mod test { + use super::*; + use crate::gtk_init::gtk_init; + + // Enabling this test pushes tests on the TextField into an infinite loop. That likely indicates a bad interaction within the TextField itself, and that is going to need to be fixed. + #[test] + #[ignore] + fn it_allows_valid_dates() { + let reference = chrono::NaiveDate::from_ymd_opt(2006, 01, 02).unwrap(); + let field = DateField::new(reference); + field.imp().year.set_value(Some(2023)); + field.imp().month.set_value(Some(10)); + field.imp().day.set_value(Some(13)); + // assert!(field.is_valid()); + } + + #[test] + #[ignore] + fn it_disallows_out_of_range_months() { + unimplemented!() + } + + #[test] + #[ignore] + fn it_allows_days_within_range_for_month() { + unimplemented!() + } +} diff --git a/fitnesstrax/app/src/components/date_range.rs b/fitnesstrax/app/src/components/date_range.rs new file mode 100644 index 0000000..6cb0198 --- /dev/null +++ b/fitnesstrax/app/src/components/date_range.rs @@ -0,0 +1,170 @@ +/* +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::{ + components::{DateField}, + types::DayInterval, +}; +use chrono::{Duration, Local, Months}; +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; +use std::{cell::RefCell}; + +type OnSearch = dyn Fn(DayInterval) + 'static; + +pub struct DateRangePickerPrivate { + start: DateField, + end: DateField, + + on_search: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for DateRangePickerPrivate { + const NAME: &'static str = "DateRangePicker"; + type Type = DateRangePicker; + type ParentType = gtk::Box; + + fn new() -> Self { + let default_date = Local::now().date_naive(); + Self { + start: DateField::new(default_date), + end: DateField::new(default_date), + on_search: RefCell::new(Box::new(|_| {})), + } + } +} + +impl ObjectImpl for DateRangePickerPrivate {} +impl WidgetImpl for DateRangePickerPrivate {} +impl BoxImpl for DateRangePickerPrivate {} + +glib::wrapper! { + pub struct DateRangePicker(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + + +impl DateRangePicker { + pub fn connect_on_search(&self, f: OnSearch) + where + OnSearch: Fn(DayInterval) + 'static, + { + *self.imp().on_search.borrow_mut() = Box::new(f); + } + + fn set_interval(&self, start: chrono::NaiveDate, end: chrono::NaiveDate) { + self.imp().start.set_date(start); + self.imp().end.set_date(end); + } + + fn interval(&self) -> DayInterval { + DayInterval { + start: self.imp().start.date(), + end: self.imp().end.date(), + } + } +} + + +impl Default for DateRangePicker { + fn default() -> Self { + let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); + + let search_button = gtk::Button::with_label("Search"); + search_button.connect_clicked({ + let s = s.clone(); + move |_| (s.imp().on_search.borrow())(s.interval()) + }); + + let last_week_button = gtk::Button::builder().label("last week").build(); + last_week_button.connect_clicked({ + let s = s.clone(); + move |_| { + let end = Local::now().date_naive(); + let start = end - Duration::days(7); + s.set_interval(start, end); + (s.imp().on_search.borrow())(s.interval()); + } + }); + + let two_weeks_button = gtk::Button::builder().label("last two weeks").build(); + two_weeks_button.connect_clicked({ + let s = s.clone(); + move |_| { + let end = Local::now().date_naive(); + let start = end - Duration::days(14); + s.set_interval(start, end); + (s.imp().on_search.borrow())(s.interval()); + } + }); + + let last_month_button = gtk::Button::builder().label("last month").build(); + last_month_button.connect_clicked({ + let s = s.clone(); + move |_| { + let end = Local::now().date_naive(); + let start = end - Months::new(1); + s.set_interval(start, end); + (s.imp().on_search.borrow())(s.interval()); + } + }); + + let six_months_button = gtk::Button::builder().label("last six months").build(); + six_months_button.connect_clicked({ + let s = s.clone(); + move |_| { + let end = Local::now().date_naive(); + let start = end - Months::new(6); + s.set_interval(start, end); + (s.imp().on_search.borrow())(s.interval()); + } + }); + + let last_year_button = gtk::Button::builder().label("last year").build(); + last_year_button.connect_clicked({ + let s = s.clone(); + move |_| { + let end = Local::now().date_naive(); + let start = end - Months::new(12); + s.set_interval(start, end); + (s.imp().on_search.borrow())(s.interval()); + } + }); + + let date_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .build(); + date_row.append(&s.imp().start); + date_row.append(>k::Label::new(Some("to"))); + date_row.append(&s.imp().end); + date_row.append(&search_button); + + let quick_picker = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .build(); + quick_picker.append(&last_week_button); + quick_picker.append(&two_weeks_button); + quick_picker.append(&last_month_button); + quick_picker.append(&six_months_button); + quick_picker.append(&last_year_button); + + s.append(&date_row); + s.append(&quick_picker); + + s + } +} diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index c1da4fd..3496e9b 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -21,6 +21,12 @@ pub use action_group::ActionGroup; mod day; pub use day::{DayDetail, DayEdit, DaySummary}; +mod date_field; +pub use date_field::DateField; + +mod date_range; +pub use date_range::DateRangePicker; + mod singleton; pub use singleton::{Singleton, SingletonImpl}; @@ -28,7 +34,7 @@ mod steps; pub use steps::{steps_editor, Steps}; mod text_entry; -pub use text_entry::{distance_field, duration_field, time_field, weight_field, TextEntry}; +pub use text_entry::{distance_field, duration_field, time_field, weight_field, i32_field_builder, month_field_builder, 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 11ba591..b408247 100644 --- a/fitnesstrax/app/src/components/steps.rs +++ b/fitnesstrax/app/src/components/steps.rs @@ -46,11 +46,15 @@ pub fn steps_editor(value: Option, on_update: OnUpdate) -> TextEn where OnUpdate: Fn(Option) + 'static, { - TextEntry::new( - "0", - value, - |v| format!("{}", v), - |v| v.parse::().map_err(|_| ParseError), - on_update, - ) + let text_entry = TextEntry::builder() + .with_placeholder( "0".to_owned()) + .with_renderer(|v| format!("{}", v)) + .with_parser(|v| v.parse::().map_err(|_| ParseError)) + .with_on_update(on_update); + + if let Some(time) = value { + text_entry.with_value(time) + } else { + text_entry + }.build() } diff --git a/fitnesstrax/app/src/components/text_entry.rs b/fitnesstrax/app/src/components/text_entry.rs index a8f89cb..3922a71 100644 --- a/fitnesstrax/app/src/components/text_entry.rs +++ b/fitnesstrax/app/src/components/text_entry.rs @@ -23,11 +23,13 @@ use std::{cell::RefCell, rc::Rc}; pub type Parser = dyn Fn(&str) -> Result; pub type OnUpdate = dyn Fn(Option); +// TextEntry is not a proper widget because I was never able to figure out how to do a type parameterization on a GTK widget. #[derive(Clone)] pub struct TextEntry { value: Rc>>, widget: gtk::Entry, + renderer: Rc String>, parser: Rc>, on_update: Rc>, } @@ -44,28 +46,20 @@ impl std::fmt::Debug for TextEntry { // I do not understand why the data should be 'static. impl TextEntry { - pub fn new( - placeholder: &str, - value: Option, - renderer: R, - parser: V, - on_update: U, - ) -> Self - where - R: Fn(&T) -> String + 'static, - V: Fn(&str) -> Result + 'static, - U: Fn(Option) + 'static, - { - let widget = gtk::Entry::builder().placeholder_text(placeholder).build(); - if let Some(ref v) = value { - widget.set_text(&renderer(v)) + fn from_builder(builder: TextEntryBuilder) -> TextEntry { + let widget = gtk::Entry::builder() + .placeholder_text(builder.placeholder) + .build(); + if let Some(ref v) = builder.value { + widget.set_text(&(builder.renderer)(v)) } let s = Self { - value: Rc::new(RefCell::new(value)), + value: Rc::new(RefCell::new(builder.value)), widget, - parser: Rc::new(parser), - on_update: Rc::new(on_update), + renderer: Rc::new(builder.renderer), + parser: Rc::new(builder.parser), + on_update: Rc::new(builder.on_update), }; s.widget.buffer().connect_text_notify({ @@ -73,9 +67,27 @@ impl TextEntry { move |buffer| s.handle_text_change(buffer) }); + if let Some(length) = builder.length { + s.widget.set_max_length(length.try_into().unwrap()); + } + + // let classes: Vec<&str> = builder.css_classes.iter(|v| v.as_ref()).collect(); + let classes: Vec<&str> = builder.css_classes.iter().map(AsRef::as_ref).collect(); + s.widget.set_css_classes(&classes); + s } + pub fn builder() -> TextEntryBuilder { + TextEntryBuilder::default() + } + + pub fn set_value(&self, val: Option) { + if let Some(ref v) = val { + self.widget.set_text(&(self.renderer)(v)); + } + } + fn handle_text_change(&self, buffer: >k::EntryBuffer) { if buffer.text().is_empty() { *self.value.borrow_mut() = None; @@ -106,7 +118,85 @@ impl TextEntry { } } -#[allow(unused)] +pub struct TextEntryBuilder { + placeholder: String, + value: Option, + length: Option, + css_classes: Vec, + renderer: Box String>, + parser: Box>, + on_update: Box>, +} + +impl Default for TextEntryBuilder { + fn default() -> TextEntryBuilder { + TextEntryBuilder { + placeholder: "".to_owned(), + value: None, + length: None, + css_classes: vec![], + renderer: Box::new(|_| "".to_owned()), + parser: Box::new(|_| Err(ParseError)), + on_update: Box::new(|_| {}), + } + } +} + +impl TextEntryBuilder { + pub fn build(self) -> TextEntry { + TextEntry::from_builder(self) + } + + pub fn with_placeholder(self, placeholder: String) -> Self { + Self { + placeholder, + ..self + } + } + + pub fn with_value(self, value: T) -> Self { + Self { + value: Some(value), + ..self + } + } + + pub fn with_length(self, length: usize) -> Self { + Self { + length: Some(length), + ..self + } + } + + pub fn with_css_classes(self, classes: Vec) -> Self { + Self { + css_classes: classes, + ..self + } + } + + pub fn with_renderer(self, renderer: impl Fn(&T) -> String + 'static) -> Self { + Self { + renderer: Box::new(renderer), + ..self + } + } + + pub fn with_parser(self, parser: impl Fn(&str) -> Result + 'static) -> Self { + Self { + parser: Box::new(parser), + ..self + } + } + + pub fn with_on_update(self, on_update: impl Fn(Option) + 'static) -> Self { + Self { + on_update: Box::new(on_update), + ..self + } + } +} + pub fn time_field( value: Option, on_update: OnUpdate, @@ -114,16 +204,20 @@ pub fn time_field( where OnUpdate: Fn(Option) + 'static, { - TextEntry::new( - "HH:MM", - value, - |val| val.format(FormatOption::Abbreviated), - TimeFormatter::parse, - on_update, - ) + let text_entry = TextEntry::builder() + .with_placeholder("HH:MM".to_owned()) + .with_renderer(|val: &TimeFormatter| val.format(FormatOption::Abbreviated)) + .with_parser(TimeFormatter::parse) + .with_on_update(on_update); + + if let Some(time) = value { + text_entry.with_value(time) + } else { + text_entry + } + .build() } -#[allow(unused)] pub fn distance_field( value: Option, on_update: OnUpdate, @@ -131,16 +225,20 @@ pub fn distance_field( where OnUpdate: Fn(Option) + 'static, { - TextEntry::new( - "0 km", - value, - |val| val.format(FormatOption::Abbreviated), - DistanceFormatter::parse, - on_update, - ) + let text_entry = TextEntry::builder() + .with_placeholder("0 km".to_owned()) + .with_renderer(|val: &DistanceFormatter| val.format(FormatOption::Abbreviated)) + .with_parser(DistanceFormatter::parse) + .with_on_update(on_update); + + if let Some(distance) = value { + text_entry.with_value(distance) + } else { + text_entry + } + .build() } -#[allow(unused)] pub fn duration_field( value: Option, on_update: OnUpdate, @@ -148,13 +246,18 @@ pub fn duration_field( where OnUpdate: Fn(Option) + 'static, { - TextEntry::new( - "0 m", - value, - |val| val.format(FormatOption::Abbreviated), - DurationFormatter::parse, - on_update, - ) + let text_entry = TextEntry::builder() + .with_placeholder("0 m".to_owned()) + .with_renderer(|val: &DurationFormatter| val.format(FormatOption::Abbreviated)) + .with_parser(DurationFormatter::parse) + .with_on_update(on_update); + + if let Some(duration) = value { + text_entry.with_value(duration) + } else { + text_entry + } + .build() } pub fn weight_field( weight: Option, @@ -163,13 +266,39 @@ pub fn weight_field( where OnUpdate: Fn(Option) + 'static, { - TextEntry::new( - "0 kg", - weight, - |val| val.format(FormatOption::Abbreviated), - WeightFormatter::parse, - on_update, - ) + let text_entry = TextEntry::builder() + .with_placeholder("0 kg".to_owned()) + .with_renderer(|val: &WeightFormatter| val.format(FormatOption::Abbreviated)) + .with_parser(WeightFormatter::parse) + .with_on_update(on_update); + if let Some(weight) = weight { + text_entry.with_value(weight) + } else { + text_entry + } + .build() +} + +pub fn i32_field_builder() -> TextEntryBuilder +{ + TextEntry::builder() + .with_placeholder("0".to_owned()) + .with_renderer(|val| format!("{}", val)) + .with_parser(|v| v.parse::().map_err(|_| ParseError)) +} + +pub fn month_field_builder() -> TextEntryBuilder +{ + TextEntry::builder() + .with_placeholder("0".to_owned()) + .with_renderer(|val| format!("{}", val)) + .with_parser(|v| { + let val = v.parse::().map_err(|_| ParseError)?; + if val == 0 || val > 12 { + return Err(ParseError); + } + Ok(val) + }) } #[cfg(test)] @@ -180,16 +309,15 @@ mod test { fn setup_u32_entry() -> (Rc>>, TextEntry) { let current_value = Rc::new(RefCell::new(None)); - let entry = TextEntry::new( - "step count", - None, - |steps| format!("{}", steps), - |v| v.parse::().map_err(|_| ParseError), - { + let entry = TextEntry::builder() + .with_placeholder("step count".to_owned()) + .with_renderer(|steps| format!("{}", steps)) + .with_parser(|v| v.parse::().map_err(|_| ParseError)) + .with_on_update({ let current_value = current_value.clone(); move |v| *current_value.borrow_mut() = v - }, - ); + }) + .build(); (current_value, entry) } diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index ead6184..02848ee 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -15,9 +15,11 @@ You should have received a copy of the GNU General Public License along with Fit */ use crate::{ - app::App, components::DaySummary, types::DayInterval, view_models::DayDetailViewModel, + app::App, + components::{DateRangePicker, DaySummary}, + types::DayInterval, + view_models::DayDetailViewModel, }; - use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; use std::{cell::RefCell, rc::Rc}; @@ -27,7 +29,6 @@ use std::{cell::RefCell, rc::Rc}; /// records. pub struct HistoricalViewPrivate { app: Rc>>, - time_window: Rc>, list_view: gtk::ListView, } @@ -48,7 +49,6 @@ impl ObjectSubclass for HistoricalViewPrivate { let s = Self { app: Rc::new(RefCell::new(None)), - time_window: Rc::new(RefCell::new(DayInterval::default())), list_view: gtk::ListView::builder() .factory(&factory) .single_click_activate(true) @@ -106,6 +106,12 @@ impl HistoricalView { *s.imp().app.borrow_mut() = Some(app); + let date_range_picker = DateRangePicker::default(); + date_range_picker.connect_on_search({ + let s = s.clone(); + move |interval| s.set_interval(interval) + }); + let mut model = gio::ListStore::new::(); model.extend(interval.days().map(Date::new)); s.imp() @@ -122,7 +128,15 @@ impl HistoricalView { } }); - s.append(&s.imp().list_view); + let scroller = gtk::ScrolledWindow::builder() + .child(&s.imp().list_view) + .hexpand(true) + .vexpand(true) + .hscrollbar_policy(gtk::PolicyType::Never) + .build(); + + s.append(&date_range_picker); + s.append(&scroller); s } @@ -134,10 +148,6 @@ impl HistoricalView { .list_view .set_model(Some(>k::NoSelection::new(Some(model)))); } - - pub fn time_window(&self) -> DayInterval { - self.imp().time_window.borrow().clone() - } } #[derive(Default)]