diff --git a/Cargo.lock b/Cargo.lock index fe046b8..c2c4b54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1021,6 +1021,9 @@ name = "fitnesstrax" version = "0.1.0" dependencies = [ "async-channel", + "chrono", + "chrono-tz", + "dimensioned 0.8.0", "emseries", "ft-core", "gio", diff --git a/fitnesstrax/app/Cargo.toml b/fitnesstrax/app/Cargo.toml index b450f5d..bfb1eca 100644 --- a/fitnesstrax/app/Cargo.toml +++ b/fitnesstrax/app/Cargo.toml @@ -8,6 +8,9 @@ edition = "2021" [dependencies] adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] } async-channel = { version = "2.1" } +chrono = { version = "0.4" } +chrono-tz = { version = "0.8" } +dimensioned = { version = "0.8", features = [ "serde" ] } emseries = { path = "../../emseries" } ft-core = { path = "../core" } gio = { version = "0.18" } diff --git a/fitnesstrax/app/resources/style.css b/fitnesstrax/app/resources/style.css index 314fd86..1f3aea4 100644 --- a/fitnesstrax/app/resources/style.css +++ b/fitnesstrax/app/resources/style.css @@ -19,3 +19,15 @@ padding: 8px; } +.daysummary { + padding: 8px; +} + +.daysummary-date { + font-size: larger; + margin-bottom: 8px; +} + +.daysummary-weight { + margin: 4px; +} diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index c6d834b..0326a22 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -20,6 +20,11 @@ use crate::{ }; use adw::prelude::*; use async_channel::Sender; +use chrono::{NaiveDate, TimeZone}; +use chrono_tz::America::Anchorage; +use dimensioned::si::{KG, M, S}; +use emseries::DateTimeTz; +use ft_core::{Steps, TimeDistance, TraxRecord, Weight}; use gio::resources_lookup_data; use gtk::STYLE_PROVIDER_PRIORITY_USER; use std::path::PathBuf; @@ -41,7 +46,7 @@ impl AppWindow { /// adw_app is an Adwaita application. Application windows need to have access to this, but /// otherwise we don't use this. /// - /// app is a core [App] object which encapsulates all of the basic logic. + /// app is a core [crate::app::App] object which encapsulates all of the basic logic. pub fn new( app_id: &str, resource_path: &str, @@ -144,7 +149,39 @@ impl AppWindow { }) .upcast(), ), - ViewName::Historical => View::Historical(HistoricalView::new().upcast()), + ViewName::Historical => View::Historical( + HistoricalView::new(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: DateTimeTz( + Anchorage.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: DateTimeTz( + Anchorage.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()), + }), + ]) + .upcast(), + ), } } } diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs new file mode 100644 index 0000000..1026a20 --- /dev/null +++ b/fitnesstrax/app/src/components/day.rs @@ -0,0 +1,90 @@ +/* +Copyright 2023, 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 chrono::NaiveDate; +// use ft_core::TraxRecord; +use ft_core::TraxRecord; +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; +use std::cell::RefCell; + +#[derive(Default)] +pub struct DaySummaryPrivate { + date: gtk::Label, + weight: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for DaySummaryPrivate { + const NAME: &'static str = "DaySummary"; + type Type = DaySummary; + 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 DaySummaryPrivate {} +impl WidgetImpl for DaySummaryPrivate {} +impl BoxImpl for DaySummaryPrivate {} + +glib::wrapper! { + /// The DaySummary displays one day's activities in a narrative style. This is meant to give + /// an overall feel of everything that happened during the day without going into details. + pub struct DaySummary(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + +impl DaySummary { + pub fn new() -> Self { + let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); + s.set_css_classes(&["daysummary"]); + + s.append(&s.imp().date); + + s + } + + pub fn set_data(&self, date: chrono::NaiveDate, records: Vec) { + self.imp() + .date + .set_text(&date.format("%Y-%m-%d").to_string()); + + if let Some(ref weight_label) = *self.imp().weight.borrow() { + self.remove(weight_label); + } + + if let Some(TraxRecord::Weight(weight_record)) = + records.iter().filter(|f| f.is_weight()).next() + { + let label = gtk::Label::builder() + .halign(gtk::Align::Start) + .label(&format!("{}", weight_record.weight)) + .css_classes(["daysummary-weight"]) + .build(); + self.append(&label); + *self.imp().weight.borrow_mut() = Some(label); + } + } +} diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index 7fcba76..858f6c2 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -14,6 +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 . */ +mod day; + +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/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index b69e960..c3d1cf0 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -14,8 +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::components::DaySummary; +use emseries::{Recordable, Timestamp}; +use ft_core::TraxRecord; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; +use std::{cell::RefCell, collections::HashMap}; /// 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 @@ -38,17 +42,169 @@ impl WidgetImpl for HistoricalViewPrivate {} impl BoxImpl for HistoricalViewPrivate {} glib::wrapper! { - pub struct HistoricalView(ObjectSubclass) @extends gtk::Box, gtk::Widget; + pub struct HistoricalView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; } impl HistoricalView { - pub fn new() -> Self { + pub fn new(records: Vec) -> Self { let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); - let label = gtk::Label::builder() - .label("Database has been configured and now it is time to show data") + 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 + .downcast_ref::() + .expect("should be a ListItem") + .set_child(Some(&DaySummary::new())); + }); + + factory.connect_bind(move |_, list_item| { + let records = list_item + .downcast_ref::() + .expect("should be a ListItem") + .item() + .and_downcast::() + .expect("should be a DaySummary"); + + let summary = list_item + .downcast_ref::() + .expect("should be a ListItem") + .child() + .and_downcast::() + .expect("should be a DaySummary"); + + 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(); - s.append(&label); + 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()); + }); + + s.append(&lst); + s } } + +#[derive(Default)] +pub struct DayRecordsPrivate { + date: RefCell, + records: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for DayRecordsPrivate { + const NAME: &'static str = "DayRecords"; + type Type = DayRecords; +} + +impl ObjectImpl for DayRecordsPrivate {} + +glib::wrapper! { + pub struct DayRecords(ObjectSubclass); +} + +impl DayRecords { + pub fn new(date: chrono::NaiveDate, records: Vec) -> Self { + let s: Self = Object::builder().build(); + + *s.imp().date.borrow_mut() = date; + *s.imp().records.borrow_mut() = records; + + s + } + + pub fn date(&self) -> chrono::NaiveDate { + self.imp().date.borrow().clone() + } + + pub fn records(&self) -> Vec { + self.imp().records.borrow().clone() + } + + pub fn add_record(&self, record: TraxRecord) { + self.imp().records.borrow_mut().push(record); + } +} + +struct GroupedRecords(Vec); + +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.0.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::>(), + ) + } +} + +#[cfg(test)] +mod test { + use super::GroupedRecords; + use chrono::{NaiveDate, TimeZone}; + use chrono_tz::America::Anchorage; + use dimensioned::si::{KG, M, S}; + use emseries::DateTimeTz; + use ft_core::{Steps, TimeDistance, TraxRecord, Weight}; + + #[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: DateTimeTz(Anchorage.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: DateTimeTz(Anchorage.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; + assert_eq!(groups.len(), 3); + } +} diff --git a/fitnesstrax/core/src/lib.rs b/fitnesstrax/core/src/lib.rs index 9d7103a..d454169 100644 --- a/fitnesstrax/core/src/lib.rs +++ b/fitnesstrax/core/src/lib.rs @@ -4,4 +4,4 @@ use emseries::DateTimeTz; mod legacy; mod types; -pub use types::TraxRecord; +pub use types::{Steps, TimeDistance, TraxRecord, Weight}; diff --git a/fitnesstrax/core/src/types.rs b/fitnesstrax/core/src/types.rs index 4c332d1..4de0da4 100644 --- a/fitnesstrax/core/src/types.rs +++ b/fitnesstrax/core/src/types.rs @@ -18,8 +18,8 @@ pub struct SetRep { /// The number of steps one takes in a single day. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Steps { - date: NaiveDate, - count: u32, + pub date: NaiveDate, + pub count: u32, } /// TimeDistance represents workouts characterized by a duration and a distance travelled. These @@ -34,22 +34,22 @@ pub struct TimeDistance { /// if one moved two timezones to the east. This is kind of nonsensical from a human /// perspective, so the DateTimeTz keeps track of the precise time in UTC, but also the /// timezone in which the event was recorded. - datetime: DateTimeTz, + pub datetime: DateTimeTz, /// The distance travelled. This is optional because such a workout makes sense even without /// the distance. - distance: Option>, + pub distance: Option>, /// The duration of the workout, which is also optional. Some people may keep track of the /// amount of distance travelled without tracking the duration. - duration: Option>, - comments: Option, + pub duration: Option>, + pub comments: Option, } /// A singular daily weight measurement. Weight changes slowly enough that it seems unlikely to /// need to track more than a single weight in a day. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Weight { - date: NaiveDate, - weight: si::Kilogram, + pub date: NaiveDate, + pub weight: si::Kilogram, } /// The unified data structure for all records that are part of the app. @@ -64,6 +64,15 @@ pub enum TraxRecord { Weight(Weight), } +impl TraxRecord { + pub fn is_weight(&self) -> bool { + match self { + TraxRecord::Weight(_) => true, + _ => false, + } + } +} + impl Recordable for TraxRecord { fn timestamp(&self) -> Timestamp { match self {