diff --git a/emseries/src/types.rs b/emseries/src/types.rs index 80f215d..018a99b 100644 --- a/emseries/src/types.rs +++ b/emseries/src/types.rs @@ -178,7 +178,7 @@ impl fmt::Display for RecordId { /// directly, as the database will create them. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct Record { - pub(crate) id: RecordId, + pub id: RecordId, pub data: T, } diff --git a/fitnesstrax/app/resources/style.css b/fitnesstrax/app/resources/style.css index 1f3aea4..2814994 100644 --- a/fitnesstrax/app/resources/style.css +++ b/fitnesstrax/app/resources/style.css @@ -2,32 +2,38 @@ margin: 64px; } -.welcome-title { +.welcome__title { font-size: larger; padding: 8px; } -.welcome-content { +.welcome__content { padding: 8px; } -.welcome-footer { +.welcome__footer { } -.dialog-row { - margin: 8px 0px 8px 0px; +.historical { + margin: 32px; + border-radius: 8px; +} + +.day-summary { padding: 8px; } -.daysummary { - padding: 8px; -} - -.daysummary-date { +.day-summary__date { font-size: larger; margin-bottom: 8px; } -.daysummary-weight { +.day-summary__weight { margin: 4px; } + +.weight-view { + padding: 8px; + margin: 8px; +} + diff --git a/fitnesstrax/app/src/app.rs b/fitnesstrax/app/src/app.rs index f8de8fc..655b2c4 100644 --- a/fitnesstrax/app/src/app.rs +++ b/fitnesstrax/app/src/app.rs @@ -14,11 +14,12 @@ 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::DayInterval; use chrono::NaiveDate; use emseries::{time_range, Record, RecordId, Series, Timestamp}; use ft_core::TraxRecord; use std::{ - path::{Path, PathBuf}, + path::PathBuf, sync::{Arc, RwLock}, }; use tokio::runtime::Runtime; @@ -97,7 +98,7 @@ impl App { .map_err(|_| AppError::Unhandled) } - pub async fn save_record(&self, record: Record) -> Result<(), AppError> { + pub async fn update_record(&self, record: Record) -> Result<(), AppError> { let db = self.database.clone(); self.runtime .spawn_blocking(move || { diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index 6e6c975..69d6ab1 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -16,10 +16,13 @@ You should have received a copy of the GNU General Public License along with Fit use crate::{ app::App, + components::DayDetail, views::{HistoricalView, PlaceholderView, View, WelcomeView}, }; use adw::prelude::*; use chrono::{Duration, Local}; +use emseries::Record; +use ft_core::TraxRecord; use gio::resources_lookup_data; use gtk::STYLE_PROVIDER_PRIORITY_USER; use std::{cell::RefCell, path::PathBuf, rc::Rc}; @@ -29,7 +32,6 @@ use std::{cell::RefCell, path::PathBuf, rc::Rc}; #[derive(Clone)] pub struct AppWindow { app: App, - app_id: String, layout: gtk::Box, current_view: Rc>, settings: gio::Settings, @@ -81,7 +83,6 @@ impl AppWindow { let initial_view = View::Placeholder(PlaceholderView::new().upcast()); - // layout.append(&header); layout.append(&initial_view.widget()); let nav_layout = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -98,24 +99,25 @@ impl AppWindow { window.set_content(Some(&navigation)); window.present(); + let gesture = gtk::GestureClick::new(); + gesture.connect_released(|_, _, _, _| println!("detected gesture")); + layout.add_controller(gesture); + let s = Self { app: ft_app, - app_id: app_id.to_owned(), layout, current_view: Rc::new(RefCell::new(initial_view)), settings: gio::Settings::new(app_id), navigation, }; - glib::spawn_future_local({ + s.load_records(); + + s.navigation.connect_popped({ let s = s.clone(); - async move { - let end = Local::now().date_naive(); - let start = end - Duration::days(7); - match s.app.records(start, end).await { - Ok(_) => s.show_historical_view(), - Err(_) => s.show_welcome_view(), - } + move |_, _| match *s.current_view.borrow() { + View::Historical(_) => s.load_records(), + _ => {} } }); @@ -130,11 +132,48 @@ impl AppWindow { self.swap_main(view); } - fn show_historical_view(&self) { - let view = View::Historical(HistoricalView::new(vec![])); + fn show_historical_view(&self, records: Vec>) { + let view = View::Historical(HistoricalView::new(records, { + let s = self.clone(); + Rc::new(move |date, records| { + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(&adw::HeaderBar::new()); + layout.append(&DayDetail::new( + date, + records, + { + let s = s.clone(); + move |record| s.on_put_record(record) + }, + { + let s = s.clone(); + move |record| s.on_update_record(record) + }, + )); + let page = &adw::NavigationPage::builder() + .title(date.format("%Y-%m-%d").to_string()) + .child(&layout) + .build(); + s.navigation.push(page); + }) + })); self.swap_main(view); } + fn load_records(&self) { + glib::spawn_future_local({ + let s = self.clone(); + async move { + let end = Local::now().date_naive(); + let start = end - Duration::days(7); + match s.app.records(start, end).await { + Ok(records) => s.show_historical_view(records), + Err(_) => s.show_welcome_view(), + } + } + }); + } + // Switch views. // // This function only replaces the old view with the one which matches the current view state. @@ -153,9 +192,27 @@ impl AppWindow { async move { if s.app.open_db(path.clone()).await.is_ok() { let _ = s.settings.set("series-path", path.to_str().unwrap()); - s.show_historical_view(); + s.load_records(); } } }); } + + fn on_put_record(&self, record: TraxRecord) { + glib::spawn_future_local({ + let s = self.clone(); + async move { + s.app.put_record(record).await; + } + }); + } + + fn on_update_record(&self, record: Record) { + glib::spawn_future_local({ + let s = self.clone(); + async move { + s.app.update_record(record).await; + } + }); + } } diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 1026a20..19ebda5 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -16,12 +16,14 @@ You should have received a copy of the GNU General Public License along with Fit // use chrono::NaiveDate; // use ft_core::TraxRecord; -use ft_core::TraxRecord; +use chrono::{Local, NaiveDate}; +use dimensioned::si; +use emseries::Record; +use ft_core::{RecordType, TimeDistance, TraxRecord, Weight}; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; -use std::cell::RefCell; +use std::{cell::RefCell, rc::Rc}; -#[derive(Default)] pub struct DaySummaryPrivate { date: gtk::Label, weight: RefCell>, @@ -35,7 +37,7 @@ impl ObjectSubclass for DaySummaryPrivate { fn new() -> Self { let date = gtk::Label::builder() - .css_classes(["daysummary-date"]) + .css_classes(["day-summary__date"]) .halign(gtk::Align::Start) .build(); Self { @@ -59,14 +61,14 @@ impl DaySummary { pub fn new() -> Self { let s: Self = Object::builder().build(); s.set_orientation(gtk::Orientation::Vertical); - s.set_css_classes(&["daysummary"]); + s.set_css_classes(&["day-summary"]); s.append(&s.imp().date); s } - pub fn set_data(&self, date: chrono::NaiveDate, records: Vec) { + pub fn set_data(&self, date: chrono::NaiveDate, records: Vec>) { self.imp() .date .set_text(&date.format("%Y-%m-%d").to_string()); @@ -75,16 +77,357 @@ impl DaySummary { self.remove(weight_label); } - if let Some(TraxRecord::Weight(weight_record)) = - records.iter().filter(|f| f.is_weight()).next() + if let Some(Record { + data: TraxRecord::Weight(weight_record), + .. + }) = records.iter().filter(|f| f.data.is_weight()).next() { let label = gtk::Label::builder() .halign(gtk::Align::Start) .label(&format!("{}", weight_record.weight)) - .css_classes(["daysummary-weight"]) + .css_classes(["day-summary__weight"]) .build(); self.append(&label); *self.imp().weight.borrow_mut() = Some(label); } + + /* + self.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label("15km of biking in 60 minutes") + .build(), + ); + */ + } +} + +pub struct DayDetailPrivate { + date: gtk::Label, + weight: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for DayDetailPrivate { + const NAME: &'static str = "DayDetail"; + type Type = DayDetail; + type ParentType = gtk::Box; + + fn new() -> Self { + let date = gtk::Label::builder() + .css_classes(["daysummary-date"]) + .halign(gtk::Align::Start) + .build(); + Self { + date, + weight: RefCell::new(None), + } + } +} + +impl ObjectImpl for DayDetailPrivate {} +impl WidgetImpl for DayDetailPrivate {} +impl BoxImpl for DayDetailPrivate {} + +glib::wrapper! { + pub struct DayDetail(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + +impl DayDetail { + pub fn new( + date: chrono::NaiveDate, + records: Vec>, + on_put_record: PutRecordFn, + on_update_record: UpdateRecordFn, + ) -> Self + where + PutRecordFn: Fn(TraxRecord) + 'static, + UpdateRecordFn: Fn(Record) + 'static, + { + let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); + + let click_controller = gtk::GestureClick::new(); + click_controller.connect_released({ + let s = s.clone(); + move |_, _, _, _| { + println!("clicked outside of focusable entity"); + if let Some(widget) = s.focus_child().and_downcast_ref::() { + println!("focused child is the weight view"); + widget.blur(); + } + } + }); + s.add_controller(click_controller); + + let weight_record = records.iter().find_map(|record| match record { + Record { + id, + data: TraxRecord::Weight(record), + } => Some((id.clone(), record.clone())), + _ => None, + }); + + let weight_view = match weight_record { + Some((id, data)) => WeightView::new(date.clone(), Some(data.clone()), move |weight| { + on_update_record(Record { + id: id.clone(), + data: TraxRecord::Weight(Weight { date, weight }), + }) + }), + None => WeightView::new(date.clone(), None, move |weight| { + on_put_record(TraxRecord::Weight(Weight { date, weight })); + }), + }; + s.append(&weight_view); + + records.into_iter().for_each(|record| { + let record_view = match record { + Record { + data: TraxRecord::BikeRide(record), + .. + } => Some( + TimeDistanceView::new(RecordType::BikeRide, record).upcast::(), + ), + Record { + data: TraxRecord::Row(record), + .. + } => Some(TimeDistanceView::new(RecordType::Row, record).upcast::()), + Record { + data: TraxRecord::Run(record), + .. + } => Some(TimeDistanceView::new(RecordType::Row, record).upcast::()), + Record { + data: TraxRecord::Swim(record), + .. + } => Some(TimeDistanceView::new(RecordType::Row, record).upcast::()), + Record { + data: TraxRecord::Walk(record), + .. + } => Some(TimeDistanceView::new(RecordType::Row, record).upcast::()), + _ => None, + }; + + if let Some(record_view) = record_view { + record_view.add_css_class("day-detail"); + record_view.set_halign(gtk::Align::Start); + + s.append(&record_view); + } + }); + + s + } +} + +pub struct WeightViewPrivate { + date: RefCell, + record: RefCell>, + view: RefCell, + edit: RefCell, + current: RefCell, + on_edit_finished: RefCell)>>, +} + +impl Default for WeightViewPrivate { + fn default() -> Self { + let view = gtk::Label::builder() + .css_classes(["card", "weight-view"]) + .halign(gtk::Align::Start) + .can_focus(true) + .build(); + let edit = gtk::Entry::builder().halign(gtk::Align::Start).build(); + + let current = view.clone(); + + Self { + date: RefCell::new(Local::now().date_naive()), + record: RefCell::new(None), + view: RefCell::new(view), + edit: RefCell::new(edit), + current: RefCell::new(current.upcast()), + on_edit_finished: RefCell::new(Box::new(|_| {})), + } + } +} + +#[glib::object_subclass] +impl ObjectSubclass for WeightViewPrivate { + const NAME: &'static str = "WeightView"; + type Type = WeightView; + type ParentType = gtk::Box; +} + +impl ObjectImpl for WeightViewPrivate {} +impl WidgetImpl for WeightViewPrivate {} +impl BoxImpl for WeightViewPrivate {} + +glib::wrapper! { + pub struct WeightView(ObjectSubclass) @extends gtk::Box, gtk::Widget; +} + +impl WeightView { + pub fn new( + date: NaiveDate, + weight: Option, + on_edit_finished: OnEditFinished, + ) -> Self + where + OnEditFinished: Fn(si::Kilogram) + 'static, + { + let s: Self = Object::builder().build(); + + *s.imp().on_edit_finished.borrow_mut() = Box::new(on_edit_finished); + *s.imp().date.borrow_mut() = date; + + *s.imp().record.borrow_mut() = weight; + s.view(); + + let view_click_controller = gtk::GestureClick::new(); + view_click_controller.connect_released({ + let s = s.clone(); + move |_, _, _, _| { + s.edit(); + } + }); + + s.imp().view.borrow().add_controller(view_click_controller); + s + } + + fn view(&self) { + let view = self.imp().view.borrow(); + match *self.imp().record.borrow() { + Some(ref record) => { + view.remove_css_class("dim_label"); + view.set_label(&format!("{:?}", record.weight)); + } + None => { + view.add_css_class("dim_label"); + view.set_label("No weight recorded"); + } + } + self.swap(view.clone().upcast()); + } + + fn edit(&self) { + let edit = self.imp().edit.borrow(); + match *self.imp().record.borrow() { + Some(ref record) => edit.buffer().set_text(&format!("{:?}", record.weight)), + None => edit.buffer().set_text(""), + } + self.swap(edit.clone().upcast()); + edit.grab_focus(); + } + + fn swap(&self, new_view: gtk::Widget) { + let mut current = self.imp().current.borrow_mut(); + self.remove(&*current); + self.append(&new_view); + *current = new_view; + } + + fn blur(&self) { + let edit = self.imp().edit.borrow(); + if *self.imp().current.borrow() == *edit { + let w = edit.buffer().text().parse::().unwrap(); + self.imp().on_edit_finished.borrow()(w * si::KG); + + let mut record = self.imp().record.borrow_mut(); + match *record { + Some(ref mut record) => record.weight = w * si::KG, + None => { + *record = Some(Weight { + date: self.imp().date.borrow().clone(), + weight: w * si::KG, + }) + } + } + } + self.view(); + } +} + +#[derive(Default)] +pub struct TimeDistanceViewPrivate { + record: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for TimeDistanceViewPrivate { + const NAME: &'static str = "TimeDistanceView"; + type Type = TimeDistanceView; + type ParentType = gtk::Box; +} + +impl ObjectImpl for TimeDistanceViewPrivate {} +impl WidgetImpl for TimeDistanceViewPrivate {} +impl BoxImpl for TimeDistanceViewPrivate {} + +glib::wrapper! { + pub struct TimeDistanceView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + +impl TimeDistanceView { + pub fn new(type_: RecordType, record: TimeDistance) -> Self { + let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); + s.set_hexpand(true); + + let first_row = gtk::Box::builder().homogeneous(true).build(); + + first_row.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label(&record.datetime.format("%H:%M").to_string()) + .build(), + ); + + first_row.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label(format!("{:?}", type_)) + .build(), + ); + + first_row.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label( + record + .distance + .map(|dist| format!("{}", dist)) + .unwrap_or("".to_owned()), + ) + .build(), + ); + + first_row.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label( + record + .duration + .map(|duration| format!("{}", duration)) + .unwrap_or("".to_owned()), + ) + .build(), + ); + + s.append(&first_row); + + s.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label( + record + .comments + .map(|comments| format!("{}", comments)) + .unwrap_or("".to_owned()), + ) + .build(), + ); + + s } } diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index 858f6c2..c6bce7d 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -15,8 +15,8 @@ You should have received a copy of the GNU General Public License along with Fit */ mod day; +pub use day::{DayDetail, DaySummary}; -pub use day::DaySummary; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; use std::{cell::RefCell, path::PathBuf, rc::Rc}; diff --git a/fitnesstrax/app/src/main.rs b/fitnesstrax/app/src/main.rs index 4ac8961..0811941 100644 --- a/fitnesstrax/app/src/main.rs +++ b/fitnesstrax/app/src/main.rs @@ -17,11 +17,13 @@ You should have received a copy of the GNU General Public License along with Fit mod app; mod app_window; mod components; +mod types; mod views; use adw::prelude::*; use app_window::AppWindow; use std::{env, path::PathBuf}; +use types::DayInterval; const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev"; const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax"; diff --git a/fitnesstrax/app/src/types.rs b/fitnesstrax/app/src/types.rs new file mode 100644 index 0000000..319e845 --- /dev/null +++ b/fitnesstrax/app/src/types.rs @@ -0,0 +1,47 @@ +use chrono::{Duration, Local, NaiveDate}; + +// 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 +// here, but in utilities. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DayInterval { + pub start: NaiveDate, + pub end: NaiveDate, +} + +impl Default for DayInterval { + fn default() -> Self { + Self { + start: (Local::now() - Duration::days(7)).date_naive(), + end: Local::now().date_naive(), + } + } +} + +impl DayInterval { + pub fn days(&self) -> impl Iterator { + DayIterator { + current: self.start.clone(), + end: self.end.clone(), + } + } +} + +struct DayIterator { + current: NaiveDate, + end: NaiveDate, +} + +impl Iterator for DayIterator { + type Item = NaiveDate; + + fn next(&mut self) -> Option { + if self.current <= self.end { + let val = self.current.clone(); + self.current += Duration::days(1); + Some(val) + } else { + None + } + } +} diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index dde7476..20e074d 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -14,17 +14,21 @@ 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::DaySummary; -use emseries::{Recordable, Timestamp}; +use crate::{components::DaySummary, types::DayInterval}; +use chrono::{Duration, Local, NaiveDate}; +use emseries::Record; use ft_core::TraxRecord; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; -use std::{cell::RefCell, collections::HashMap}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; /// The historical view will show a window into the main database. It will show some version of /// daily summaries, daily details, and will provide all functions the user may need for editing /// records. -pub struct HistoricalViewPrivate {} +pub struct HistoricalViewPrivate { + time_window: RefCell, + list_view: gtk::ListView, +} #[glib::object_subclass] impl ObjectSubclass for HistoricalViewPrivate { @@ -33,28 +37,6 @@ impl ObjectSubclass for HistoricalViewPrivate { type ParentType = gtk::Box; fn new() -> Self { - Self {} - } -} - -impl ObjectImpl for HistoricalViewPrivate {} -impl WidgetImpl for HistoricalViewPrivate {} -impl BoxImpl for HistoricalViewPrivate {} - -glib::wrapper! { - pub struct HistoricalView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; -} - -impl HistoricalView { - pub fn new(records: Vec) -> Self { - let s: Self = Object::builder().build(); - s.set_orientation(gtk::Orientation::Vertical); - - let day_records: GroupedRecords = GroupedRecords::from(records); - - let model = gio::ListStore::new::(); - model.extend_from_slice(&day_records.0); - let factory = gtk::SignalListItemFactory::new(); factory.connect_setup(move |_, list_item| { list_item @@ -81,30 +63,79 @@ impl HistoricalView { summary.set_data(records.date(), records.records()); }); - let lst = gtk::ListView::builder() - .model(>k::NoSelection::new(Some(model))) - .factory(&factory) - .single_click_activate(true) - .build(); - lst.connect_activate(|s, idx| { - // This gets triggered whenever the user clicks on an item on the list. What we - // actually want to do here is to open a modal dialog that shows all of the details of - // the day and which allows the user to edit items within that dialog. - let item = s.model().unwrap().item(idx).unwrap(); - let records = item.downcast_ref::().unwrap(); - println!("list item activated: [{:?}] {:?}", idx, records.date()); + Self { + time_window: RefCell::new(DayInterval::default()), + list_view: gtk::ListView::builder() + .factory(&factory) + .single_click_activate(true) + .build(), + } + } +} + +impl ObjectImpl for HistoricalViewPrivate {} +impl WidgetImpl for HistoricalViewPrivate {} +impl BoxImpl for HistoricalViewPrivate {} + +glib::wrapper! { + pub struct HistoricalView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + +impl HistoricalView { + pub fn new(records: Vec>, on_select_day: Rc) -> Self + where + SelectFn: Fn(chrono::NaiveDate, Vec>) + 'static, + { + let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); + s.set_css_classes(&["historical"]); + + let grouped_records = + GroupedRecords::new((*s.imp().time_window.borrow()).clone()).with_data(records); + + let mut model = gio::ListStore::new::(); + model.extend(grouped_records.items()); + s.imp() + .list_view + .set_model(Some(>k::NoSelection::new(Some(model)))); + + s.imp().list_view.connect_activate({ + let on_select_day = on_select_day.clone(); + move |s, idx| { + // This gets triggered whenever the user clicks on an item on the list. What we + // actually want to do here is to open a modal dialog that shows all of the details of + // the day and which allows the user to edit items within that dialog. + let item = s.model().unwrap().item(idx).unwrap(); + let records = item.downcast_ref::().unwrap(); + on_select_day(records.date(), records.records()); + } }); - s.append(&lst); + s.append(&s.imp().list_view); s } + + pub fn set_records(&self, records: Vec>) { + println!("set_records: {:?}", records); + let grouped_records = + GroupedRecords::new((self.imp().time_window.borrow()).clone()).with_data(records); + let mut model = gio::ListStore::new::(); + model.extend(grouped_records.items()); + self.imp() + .list_view + .set_model(Some(>k::NoSelection::new(Some(model)))); + } + + pub fn time_window(&self) -> DayInterval { + self.imp().time_window.borrow().clone() + } } #[derive(Default)] pub struct DayRecordsPrivate { date: RefCell, - records: RefCell>, + records: RefCell>>, } #[glib::object_subclass] @@ -120,7 +151,7 @@ glib::wrapper! { } impl DayRecords { - pub fn new(date: chrono::NaiveDate, records: Vec) -> Self { + pub fn new(date: chrono::NaiveDate, records: Vec>) -> Self { let s: Self = Object::builder().build(); *s.imp().date.borrow_mut() = date; @@ -133,36 +164,53 @@ impl DayRecords { self.imp().date.borrow().clone() } - pub fn records(&self) -> Vec { + pub fn records(&self) -> Vec> { self.imp().records.borrow().clone() } - pub fn add_record(&self, record: TraxRecord) { + pub fn add_record(&self, record: Record) { self.imp().records.borrow_mut().push(record); } } -struct GroupedRecords(Vec); +// This isn't feeling quite right. DayRecords is a glib object, but I'm not sure that I want to +// really be passing that around. It seems not generic enough. I feel like this whole grouped +// records thing can be made more generic. +struct GroupedRecords { + interval: DayInterval, + data: HashMap, +} -impl From> for GroupedRecords { - fn from(records: Vec) -> GroupedRecords { - GroupedRecords( - records - .into_iter() - .fold(HashMap::new(), |mut acc, rec| { - let date = match rec.timestamp() { - Timestamp::DateTime(dtz) => dtz.date_naive(), - Timestamp::Date(date) => date, - }; - acc.entry(date) - .and_modify(|entry: &mut DayRecords| (*entry).add_record(rec.clone())) - .or_insert(DayRecords::new(date, vec![rec])); - acc - }) - .values() - .cloned() - .collect::>(), - ) +impl GroupedRecords { + fn new(interval: DayInterval) -> Self { + let mut s = Self { + interval: interval.clone(), + data: HashMap::new(), + }; + interval.days().for_each(|date| { + let _ = s.data.insert(date, DayRecords::new(date, vec![])); + }); + s + } + + fn with_data(mut self, records: Vec>) -> Self { + records.into_iter().for_each(|record| { + self.data + .entry(record.date()) + .and_modify(|entry: &mut DayRecords| (*entry).add_record(record.clone())) + .or_insert(DayRecords::new(record.date(), vec![record])); + }); + + self + } + + fn items<'a>(&'a self) -> impl Iterator + 'a { + self.interval.days().map(|date| { + self.data + .get(&date) + .map(|rec| rec.clone()) + .unwrap_or(DayRecords::new(date, vec![])) + }) } } @@ -170,7 +218,6 @@ impl From> for GroupedRecords { mod test { use super::GroupedRecords; use chrono::{FixedOffset, NaiveDate, TimeZone}; - use chrono_tz::America::Anchorage; use dimensioned::si::{KG, M, S}; use emseries::{Record, RecordId}; use ft_core::{Steps, TimeDistance, TraxRecord, Weight}; @@ -178,36 +225,51 @@ mod test { #[test] fn groups_records() { let records = vec![ - TraxRecord::Steps(Steps { - date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(), - count: 1500, - }), - TraxRecord::Weight(Weight { - date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(), - weight: 85. * KG, - }), - TraxRecord::Weight(Weight { - date: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(), - weight: 86. * KG, - }), - TraxRecord::BikeRide(TimeDistance { - datetime: FixedOffset::west_opt(10 * 60 * 60) - .unwrap() - .with_ymd_and_hms(2019, 6, 15, 12, 0, 0) - .unwrap(), - distance: Some(1000. * M), - duration: Some(150. * S), - comments: Some("Test Comments".to_owned()), - }), - TraxRecord::BikeRide(TimeDistance { - datetime: FixedOffset::west_opt(10 * 60 * 60) - .unwrap() - .with_ymd_and_hms(2019, 6, 15, 23, 0, 0) - .unwrap(), - distance: Some(1000. * M), - duration: Some(150. * S), - comments: Some("Test Comments".to_owned()), - }), + Record { + id: RecordId::default(), + data: TraxRecord::Steps(Steps { + date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(), + count: 1500, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Weight(Weight { + date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(), + weight: 85. * KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::Weight(Weight { + date: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(), + weight: 86. * KG, + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::BikeRide(TimeDistance { + datetime: FixedOffset::west_opt(10 * 60 * 60) + .unwrap() + .with_ymd_and_hms(2019, 6, 15, 12, 0, 0) + .unwrap(), + distance: Some(1000. * M), + duration: Some(150. * S), + comments: Some("Test Comments".to_owned()), + }), + }, + Record { + id: RecordId::default(), + data: TraxRecord::BikeRide(TimeDistance { + datetime: FixedOffset::west_opt(10 * 60 * 60) + .unwrap() + .with_ymd_and_hms(2019, 6, 15, 23, 0, 0) + .unwrap(), + distance: Some(1000. * M), + duration: Some(150. * S), + comments: Some("Test Comments".to_owned()), + }), + }, ]; let groups = GroupedRecords::from(records).0; diff --git a/fitnesstrax/app/src/views/welcome_view.rs b/fitnesstrax/app/src/views/welcome_view.rs index 682bde7..1f391a3 100644 --- a/fitnesstrax/app/src/views/welcome_view.rs +++ b/fitnesstrax/app/src/views/welcome_view.rs @@ -55,11 +55,10 @@ impl WelcomeView { // branch. let title = gtk::Label::builder() .label("Welcome to FitnessTrax") - .css_classes(["welcome-title"]) + .css_classes(["welcome__title"]) .build(); let content = gtk::Box::builder() - .css_classes(["model-content"]) .orientation(gtk::Orientation::Vertical) .vexpand(true) .build(); diff --git a/fitnesstrax/core/src/lib.rs b/fitnesstrax/core/src/lib.rs index 4a3791c..207f483 100644 --- a/fitnesstrax/core/src/lib.rs +++ b/fitnesstrax/core/src/lib.rs @@ -1,3 +1,4 @@ mod legacy; + mod types; -pub use types::{Steps, TimeDistance, TraxRecord, Weight}; +pub use types::{RecordType, Steps, TimeDistance, TraxRecord, Weight}; diff --git a/fitnesstrax/core/src/types.rs b/fitnesstrax/core/src/types.rs index 7026030..401f4a8 100644 --- a/fitnesstrax/core/src/types.rs +++ b/fitnesstrax/core/src/types.rs @@ -54,6 +54,17 @@ pub struct Weight { pub weight: si::Kilogram, } +#[derive(Clone, Debug, PartialEq)] +pub enum RecordType { + BikeRide, + Row, + Run, + Steps, + Swim, + Walk, + Weight, +} + /// The unified data structure for all records that are part of the app. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum TraxRecord { @@ -67,6 +78,18 @@ pub enum TraxRecord { } impl TraxRecord { + pub fn workout_type(&self) -> RecordType { + match self { + TraxRecord::BikeRide(_) => RecordType::BikeRide, + TraxRecord::Row(_) => RecordType::Row, + TraxRecord::Run(_) => RecordType::Run, + TraxRecord::Steps(_) => RecordType::Steps, + TraxRecord::Swim(_) => RecordType::Swim, + TraxRecord::Walk(_) => RecordType::Walk, + TraxRecord::Weight(_) => RecordType::Weight, + } + } + pub fn is_weight(&self) -> bool { match self { TraxRecord::Weight(_) => true,