diff --git a/Cargo.lock b/Cargo.lock index 3aa632b..fc0a3d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,6 +945,7 @@ dependencies = [ "dimensioned 0.8.0", "emseries", "ft-core", + "gdk4", "gio", "glib", "glib-build-tools 0.18.0", @@ -1806,6 +1807,16 @@ dependencies = [ "cc", ] +[[package]] +name = "icon-test" +version = "0.1.0" +dependencies = [ + "gio", + "glib", + "gtk4", + "libadwaita", +] + [[package]] name = "idna" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index bf860b2..1812447 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "geo-types", "gm-control-panel", "hex-grid", + "icon-test", "ifc", "kifu/core", "kifu/gtk", diff --git a/emseries/src/series.rs b/emseries/src/series.rs index 310b3f7..0e6441b 100644 --- a/emseries/src/series.rs +++ b/emseries/src/series.rs @@ -110,7 +110,7 @@ where .map_err(EmseriesReadError::JSONParseError) .and_then(Record::try_from) { - Ok(record) => records.insert(record.id.clone(), record.clone()), + Ok(record) => records.insert(record.id, record.clone()), Err(EmseriesReadError::RecordDeleted(id)) => records.remove(&id), Err(err) => return Err(err), }; @@ -124,19 +124,16 @@ where /// Put a new record into the database. A unique id will be assigned to the record and /// returned. pub fn put(&mut self, entry: T) -> Result { - let uuid = RecordId::default(); - let record = Record { - id: uuid.clone(), - data: entry, - }; + let id = RecordId::default(); + let record = Record { id, data: entry }; self.update(record)?; - Ok(uuid) + Ok(id) } /// Update an existing record. The [RecordId] of the record passed into this function must match /// the [RecordId] of a record already in the database. pub fn update(&mut self, record: Record) -> Result<(), EmseriesWriteError> { - self.records.insert(record.id.clone(), record.clone()); + self.records.insert(record.id, record.clone()); let write_res = match serde_json::to_string(&RecordOnDisk { id: record.id, data: Some(record.data), @@ -166,7 +163,7 @@ where self.records.remove(uuid); let rec: RecordOnDisk = RecordOnDisk { - id: uuid.clone(), + id: *uuid, data: None, }; match serde_json::to_string(&rec) { diff --git a/emseries/src/types.rs b/emseries/src/types.rs index 3d4eb2e..9f92395 100644 --- a/emseries/src/types.rs +++ b/emseries/src/types.rs @@ -120,7 +120,7 @@ pub trait Recordable { /// Uniquely identifies a record. /// /// This is a wrapper around a basic uuid with some extra convenience methods. -#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)] pub struct RecordId(Uuid); impl Default for RecordId { @@ -166,6 +166,17 @@ impl Record { pub fn timestamp(&self) -> Timestamp { self.data.timestamp() } + + pub fn map(self, map: Map) -> Record + where + Map: Fn(T) -> U, + U: Clone + Recordable, + { + Record { + id: self.id, + data: map(self.data), + } + } } #[cfg(test)] @@ -190,7 +201,7 @@ mod test { impl Recordable for WeightRecord { fn timestamp(&self) -> Timestamp { - Timestamp::Date(self.date.clone()) + Timestamp::Date(self.date) } fn tags(&self) -> Vec { diff --git a/emseries/tests/test_io.rs b/emseries/tests/test_io.rs index 77142c4..330a875 100644 --- a/emseries/tests/test_io.rs +++ b/emseries/tests/test_io.rs @@ -20,7 +20,7 @@ extern crate emseries; #[cfg(test)] mod test { - use chrono::{format::Fixed, prelude::*}; + use chrono::{prelude::*}; use chrono_tz::Etc::UTC; use dimensioned::si::{Kilogram, Meter, Second, M, S}; @@ -42,7 +42,7 @@ mod test { impl Recordable for BikeTrip { fn timestamp(&self) -> Timestamp { - Timestamp::DateTime(self.datetime.clone()) + Timestamp::DateTime(self.datetime) } fn tags(&self) -> Vec { Vec::new() @@ -99,7 +99,7 @@ mod test { ] } - fn run_test(test: T) -> () + fn run_test(test: T) where T: FnOnce(tempfile::TempPath), { @@ -108,7 +108,7 @@ mod test { test(tmp_path); } - fn run(test: T) -> () + fn run(test: T) where T: FnOnce(Series), { @@ -280,8 +280,7 @@ mod test { UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0) .unwrap() .with_timezone(&FixedOffset::east_opt(0).unwrap()), - ) - .into(), + ), true, ), |l, r| l.timestamp().cmp(&r.timestamp()), diff --git a/fitnesstrax/app/Cargo.toml b/fitnesstrax/app/Cargo.toml index 3966c0c..2e7a012 100644 --- a/fitnesstrax/app/Cargo.toml +++ b/fitnesstrax/app/Cargo.toml @@ -16,6 +16,7 @@ emseries = { path = "../../emseries" } ft-core = { path = "../core" } gio = { version = "0.18" } glib = { version = "0.18" } +gdk = { version = "0.7", package = "gdk4" } gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] } thiserror = { version = "1.0" } tokio = { version = "1.34", features = [ "full" ] } diff --git a/fitnesstrax/app/gresources.xml b/fitnesstrax/app/gresources.xml index a597670..b26eb63 100644 --- a/fitnesstrax/app/gresources.xml +++ b/fitnesstrax/app/gresources.xml @@ -3,4 +3,15 @@ style.css + + walking2-symbolic.svg + + + + running-symbolic.svg + + + + cycling-symbolic.svg + diff --git a/fitnesstrax/app/resources/cycling-symbolic.svg b/fitnesstrax/app/resources/cycling-symbolic.svg new file mode 100644 index 0000000..d1a3f13 --- /dev/null +++ b/fitnesstrax/app/resources/cycling-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/fitnesstrax/app/resources/running-symbolic.svg b/fitnesstrax/app/resources/running-symbolic.svg new file mode 100644 index 0000000..89eba11 --- /dev/null +++ b/fitnesstrax/app/resources/running-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/fitnesstrax/app/resources/walking2-symbolic.svg b/fitnesstrax/app/resources/walking2-symbolic.svg new file mode 100644 index 0000000..c43dc8b --- /dev/null +++ b/fitnesstrax/app/resources/walking2-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/fitnesstrax/app/src/app.rs b/fitnesstrax/app/src/app.rs index 2aafc94..94c83b8 100644 --- a/fitnesstrax/app/src/app.rs +++ b/fitnesstrax/app/src/app.rs @@ -95,6 +95,20 @@ impl App { .await .unwrap() } + + pub async fn get_record(&self, id: RecordId) -> Result>, AppError> { + let db = self.database.clone(); + self.runtime + .spawn_blocking(move || { + if let Some(ref db) = *db.read().unwrap() { + Ok(db.get(&id)) + } else { + Err(AppError::NoDatabase) + } + }) + .await + .unwrap() + } } #[async_trait] diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs index 24f0484..0a127c4 100644 --- a/fitnesstrax/app/src/app_window.rs +++ b/fitnesstrax/app/src/app_window.rs @@ -54,7 +54,7 @@ impl AppWindow { let window = adw::ApplicationWindow::builder() .application(adw_app) .width_request(800) - .height_request(600) + .height_request(746) .build(); let stylesheet = String::from_utf8( @@ -99,10 +99,6 @@ 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, layout, @@ -135,14 +131,15 @@ impl AppWindow { } fn show_historical_view(&self, interval: DayInterval) { - let view = View::Historical(HistoricalView::new(self.app.clone(), interval, { + let on_select_day = { let s = self.clone(); - Rc::new(move |date| { + move |date| { let s = s.clone(); glib::spawn_future_local(async move { + let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap(); let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); layout.append(&adw::HeaderBar::new()); - let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap(); + // layout.append(&DayDetailView::new(date, records, s.app.clone())); layout.append(&DayDetailView::new(view_model)); let page = &adw::NavigationPage::builder() .title(date.format("%Y-%m-%d").to_string()) @@ -150,8 +147,14 @@ impl AppWindow { .build(); s.navigation.push(page); }); - }) - })); + } + }; + + let view = View::Historical(HistoricalView::new( + self.app.clone(), + interval, + Rc::new(on_select_day), + )); self.swap_main(view); } diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 0b8791f..289f1a5 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -17,13 +17,19 @@ You should have received a copy of the GNU General Public License along with Fit // use chrono::NaiveDate; // use ft_core::TraxRecord; use crate::{ - components::{steps_editor, weight_field, ActionGroup, Steps, WeightLabel}, - types::WeightFormatter, + components::{ + steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, WeightLabel, + }, + types::{DistanceFormatter, DurationFormatter, WeightFormatter}, view_models::DayDetailViewModel, }; +use emseries::{Record, RecordId}; +use ft_core::{TimeDistanceActivity, TraxRecord}; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; -use std::cell::RefCell; +use std::{cell::RefCell, rc::Rc}; + +use super::{time_distance::TimeDistanceEdit, time_distance_detail}; pub struct DaySummaryPrivate { date: gtk::Label, @@ -97,8 +103,15 @@ impl DaySummary { label.set_label(&format!("{} steps", s)); } row.append(&label); - self.append(&row); + + let biking_summary = view_model.time_distance_summary(TimeDistanceActivity::BikeRide); + if let Some(label) = time_distance_summary( + DistanceFormatter::from(biking_summary.0), + DurationFormatter::from(biking_summary.1), + ) { + self.append(&label); + } } } @@ -135,31 +148,6 @@ impl DayDetail { .build(), ); - /* - 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: ft_core::TraxRecord::Weight(record), - } => Some((id.clone(), record.clone())), - _ => None, - }); - */ - let top_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .build(); @@ -171,51 +159,10 @@ impl DayDetail { s.append(&top_row); - /* - records.into_iter().for_each(|record| { - let record_view = match record { - Record { - data: ft_core::TraxRecord::BikeRide(record), - .. - } => Some( - TimeDistanceView::new(ft_core::RecordType::BikeRide, record) - .upcast::(), - ), - Record { - data: ft_core::TraxRecord::Row(record), - .. - } => Some( - TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::(), - ), - Record { - data: ft_core::TraxRecord::Run(record), - .. - } => Some( - TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::(), - ), - Record { - data: ft_core::TraxRecord::Swim(record), - .. - } => Some( - TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::(), - ), - Record { - data: ft_core::TraxRecord::Walk(record), - .. - } => Some( - TimeDistanceView::new(ft_core::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); - } - }); - */ + let records = view_model.time_distance_records(); + for emseries::Record { data, .. } in records { + s.append(&time_distance_detail(data)); + } s } @@ -266,61 +213,189 @@ impl DayEdit { let s: Self = Object::builder().build(); s.set_orientation(gtk::Orientation::Vertical); s.set_hexpand(true); - *s.imp().on_finished.borrow_mut() = Box::new(on_finished); *s.imp().view_model.borrow_mut() = Some(view_model.clone()); - s.append( - &ActionGroup::builder() - .primary_action("Save", { - let s = s.clone(); - let view_model = view_model.clone(); - move || { - view_model.save(); - s.finish(); - } - }) - .secondary_action("Cancel", { - let s = s.clone(); - let view_model = view_model.clone(); - move || { - view_model.revert(); - s.finish(); - } - }) - .build(), - ); + let workout_buttons = workout_buttons(view_model.clone(), { + let s = s.clone(); + move |workout| s.add_row(workout) + }); - let top_row = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .build(); - top_row.append( - &weight_field(view_model.weight().map(WeightFormatter::from), { - let view_model = view_model.clone(); - move |w| match w { - Some(w) => view_model.set_weight(*w), - None => eprintln!("have not implemented record delete"), + view_model + .records() + .into_iter() + .filter_map({ + let s = s.clone(); + move |record| match record.data { + TraxRecord::TimeDistance(workout) => Some(TimeDistanceEdit::new(workout, { + let s = s.clone(); + move |data| { + s.update_workout(record.id, data); + } + })), + _ => None, } }) - .widget(), - ); + .for_each(|row| s.imp().workout_rows.borrow().append(&row)); - top_row.append( - &steps_editor(view_model.steps(), { - let view_model = view_model.clone(); - move |s| match s { - Some(s) => view_model.set_steps(s), - None => eprintln!("have not implemented record delete"), - } - }) - .widget(), - ); - s.append(&top_row); + s.append(&control_buttons(&s, &view_model)); + s.append(&weight_and_steps_row(&view_model)); + s.append(&*s.imp().workout_rows.borrow()); + s.append(&workout_buttons); s } fn finish(&self) { - (self.imp().on_finished.borrow())() + glib::spawn_future_local({ + let s = self.clone(); + async move { + let view_model = { + let view_model = s.imp().view_model.borrow(); + view_model + .as_ref() + .expect("DayEdit has not been initialized with the view model") + .clone() + }; + let _ = view_model.async_save().await; + (s.imp().on_finished.borrow())() + } + }); + } + + fn add_row(&self, workout: Record) { + let workout_rows = self.imp().workout_rows.borrow(); + + #[allow(clippy::single_match)] + match workout.data { + TraxRecord::TimeDistance(r) => workout_rows.append(&TimeDistanceEdit::new(r, { + let s = self.clone(); + move |data| { + println!("update workout callback on workout: {:?}", workout.id); + s.update_workout(workout.id, data) + } + })), + + _ => {} + } + } + + fn update_workout(&self, id: RecordId, data: ft_core::TimeDistance) { + if let Some(ref view_model) = *self.imp().view_model.borrow() { + let record = Record { + id, + data: TraxRecord::TimeDistance(data), + }; + view_model.update_record(record); + } } } + +fn control_buttons(s: &DayEdit, view_model: &DayDetailViewModel) -> ActionGroup { + ActionGroup::builder() + .primary_action("Save", { + let s = s.clone(); + move || s.finish() + }) + .secondary_action("Cancel", { + let s = s.clone(); + let view_model = view_model.clone(); + move || { + let s = s.clone(); + let view_model = view_model.clone(); + glib::spawn_future_local(async move { + view_model.revert().await; + s.finish(); + }); + } + }) + .build() +} + +fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box { + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .build(); + row.append( + &weight_field(view_model.weight().map(WeightFormatter::from), { + let view_model = view_model.clone(); + move |w| match w { + Some(w) => view_model.set_weight(*w), + None => eprintln!("have not implemented record delete"), + } + }) + .widget(), + ); + + row.append( + &steps_editor(view_model.steps(), { + let view_model = view_model.clone(); + move |s| match s { + Some(s) => view_model.set_steps(s), + None => eprintln!("have not implemented record delete"), + } + }) + .widget(), + ); + + row +} + +fn workout_buttons(view_model: DayDetailViewModel, add_row: AddRow) -> gtk::Box +where + AddRow: Fn(Record) + 'static, +{ + let add_row = Rc::new(add_row); + /* + let walking_button = gtk::Button::builder() + .icon_name("walking2-symbolic") + .width_request(64) + .height_request(64) + .build(); + walking_button.connect_clicked({ + let view_model = view_model.clone(); + let add_row = add_row.clone(); + move |_| { + let workout = view_model.new_time_distance(TimeDistanceActivity::Walking); + add_row(workout.map(TraxRecord::TimeDistance)); + } + }); + + let running_button = gtk::Button::builder() + .icon_name("running-symbolic") + .width_request(64) + .height_request(64) + .build(); + running_button.connect_clicked({ + let view_model = view_model.clone(); + move |_| { + let workout = view_model.new_time_distance(TimeDistanceActivity::Running); + add_row(workout.map(TraxRecord::TimeDistance)); + } + }); + */ + + let biking_button = gtk::Button::builder() + .icon_name("cycling-symbolic") + .width_request(64) + .height_request(64) + .build(); + biking_button.connect_clicked({ + let view_model = view_model.clone(); + move |_| { + let workout = view_model.new_time_distance(TimeDistanceActivity::BikeRide); + add_row(workout.map(TraxRecord::TimeDistance)); + } + }); + + let layout = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .build(); + row.append(&biking_button); + layout.append(&row); + + layout +} diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index b6c0fe3..c1da4fd 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -11,7 +11,8 @@ FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY W 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 . +You should have received a copy of the GNU General Public License along with FitnessTrax. If not, +see . */ mod action_group; @@ -27,10 +28,10 @@ mod steps; pub use steps::{steps_editor, Steps}; mod text_entry; -pub use text_entry::{weight_field, TextEntry}; +pub use text_entry::{distance_field, duration_field, time_field, weight_field, TextEntry}; mod time_distance; -pub use time_distance::TimeDistanceView; +pub use time_distance::{time_distance_detail, time_distance_summary}; mod weight; pub use weight::WeightLabel; diff --git a/fitnesstrax/app/src/components/steps.rs b/fitnesstrax/app/src/components/steps.rs index 391b43a..11ba591 100644 --- a/fitnesstrax/app/src/components/steps.rs +++ b/fitnesstrax/app/src/components/steps.rs @@ -50,7 +50,7 @@ where "0", value, |v| format!("{}", v), - move |v| v.parse::().map_err(|_| ParseError), + |v| v.parse::().map_err(|_| ParseError), on_update, ) } diff --git a/fitnesstrax/app/src/components/text_entry.rs b/fitnesstrax/app/src/components/text_entry.rs index 9cf0542..a8f89cb 100644 --- a/fitnesstrax/app/src/components/text_entry.rs +++ b/fitnesstrax/app/src/components/text_entry.rs @@ -20,8 +20,8 @@ use crate::types::{ use gtk::prelude::*; use std::{cell::RefCell, rc::Rc}; -type Parser = dyn Fn(&str) -> Result; -type OnUpdate = dyn Fn(Option); +pub type Parser = dyn Fn(&str) -> Result; +pub type OnUpdate = dyn Fn(Option); #[derive(Clone)] pub struct TextEntry { diff --git a/fitnesstrax/app/src/components/time_distance.rs b/fitnesstrax/app/src/components/time_distance.rs index 2721002..7b800c1 100644 --- a/fitnesstrax/app/src/components/time_distance.rs +++ b/fitnesstrax/app/src/components/time_distance.rs @@ -17,94 +17,204 @@ You should have received a copy of the GNU General Public License along with Fit // use crate::components::{EditView, ParseError, TextEntry}; // use chrono::{Local, NaiveDate}; // use dimensioned::si; -use ft_core::TimeDistance; +use crate::{ + components::{distance_field, duration_field, time_field}, + types::{DistanceFormatter, DurationFormatter, FormatOption, TimeFormatter}, +}; +use dimensioned::si; +use ft_core::{TimeDistance, TimeDistanceActivity}; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; -use std::cell::RefCell; +use std::{rc::Rc, cell::RefCell}; -#[derive(Default)] -pub struct TimeDistanceViewPrivate { +pub fn time_distance_summary( + distance: DistanceFormatter, + duration: DurationFormatter, +) -> Option { + let text = match (*distance > si::M, *duration > si::S) { + (true, true) => Some(format!( + "{} of biking in {}", + distance.format(FormatOption::Full), + duration.format(FormatOption::Full) + )), + (true, false) => Some(format!("{} of biking", distance.format(FormatOption::Full))), + (false, true) => Some(format!("{} of biking", duration.format(FormatOption::Full))), + (false, false) => None, + }; + + text.map(|text| gtk::Label::new(Some(&text))) +} + +pub fn time_distance_detail(record: ft_core::TimeDistance) -> gtk::Box { + let layout = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .hexpand(true) + .build(); + 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!("{:?}", record.activity)) + .build(), + ); + + first_row.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label( + record + .distance + .map(|dist| DistanceFormatter::from(dist).format(FormatOption::Abbreviated)) + .unwrap_or("".to_owned()), + ) + .build(), + ); + + first_row.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label( + record + .duration + .map(|duration| { + DurationFormatter::from(duration).format(FormatOption::Abbreviated) + }) + .unwrap_or("".to_owned()), + ) + .build(), + ); + + layout.append(&first_row); + + layout.append( + >k::Label::builder() + .halign(gtk::Align::Start) + .label( + record + .comments + .map(|comments| comments.to_string()) + .unwrap_or("".to_owned()), + ) + .build(), + ); + + layout +} + +type OnUpdate = Rc>>; + +pub struct TimeDistanceEditPrivate { #[allow(unused)] - record: RefCell>, + workout: RefCell, + on_update: OnUpdate, +} + +impl Default for TimeDistanceEditPrivate { + fn default() -> Self { + Self { + workout: RefCell::new(TimeDistance { + datetime: chrono::Utc::now().into(), + activity: TimeDistanceActivity::BikeRide, + duration: None, + distance: None, + comments: None, + }), + on_update: Rc::new(RefCell::new(Box::new(|_| {}))), + } + } } #[glib::object_subclass] -impl ObjectSubclass for TimeDistanceViewPrivate { - const NAME: &'static str = "TimeDistanceView"; - type Type = TimeDistanceView; +impl ObjectSubclass for TimeDistanceEditPrivate { + const NAME: &'static str = "TimeDistanceEdit"; + type Type = TimeDistanceEdit; type ParentType = gtk::Box; } -impl ObjectImpl for TimeDistanceViewPrivate {} -impl WidgetImpl for TimeDistanceViewPrivate {} -impl BoxImpl for TimeDistanceViewPrivate {} +impl ObjectImpl for TimeDistanceEditPrivate {} +impl WidgetImpl for TimeDistanceEditPrivate {} +impl BoxImpl for TimeDistanceEditPrivate {} glib::wrapper! { - pub struct TimeDistanceView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; + pub struct TimeDistanceEdit(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; } -impl TimeDistanceView { - pub fn new(record: TimeDistance) -> Self { +impl Default for TimeDistanceEdit { + fn default() -> 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| comments.to_string()) - .unwrap_or("".to_owned()), - ) - .build(), - ); + s.set_css_classes(&["time-distance-edit"]); s } } + +impl TimeDistanceEdit { + pub fn new(workout: TimeDistance, on_update: OnUpdate) -> Self + where + OnUpdate: Fn(TimeDistance) + 'static, + { + let s = Self::default(); + + *s.imp().workout.borrow_mut() = workout.clone(); + *s.imp().on_update.borrow_mut() = Box::new(on_update); + + let details_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .build(); + + details_row.append( + &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(); + move |d| s.update_distance(d) + }) + .widget(), + ); + details_row.append( + &duration_field(workout.duration.map(DurationFormatter::from), { + let s = s.clone(); + move |d| s.update_duration(d) + }) + .widget(), + ); + s.append(&details_row); + s.append(>k::Entry::new()); + + s + } + + fn update_time(&self, _time: Option) { + unimplemented!() + } + + fn update_distance(&self, distance: Option) { + let mut workout = self.imp().workout.borrow_mut(); + workout.distance = distance.map(|d| *d); + (self.imp().on_update.borrow())(workout.clone()); + } + + fn update_duration(&self, duration: Option) { + let mut workout = self.imp().workout.borrow_mut(); + workout.duration = duration.map(|d| *d); + (self.imp().on_update.borrow())(workout.clone()); + } +} diff --git a/fitnesstrax/app/src/components/weight.rs b/fitnesstrax/app/src/components/weight.rs index 615f3f2..2f17c82 100644 --- a/fitnesstrax/app/src/components/weight.rs +++ b/fitnesstrax/app/src/components/weight.rs @@ -14,9 +14,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ -use crate::{ - types::{FormatOption, WeightFormatter}, -}; +use crate::types::{FormatOption, WeightFormatter}; use gtk::prelude::*; pub struct WeightLabel { diff --git a/fitnesstrax/app/src/main.rs b/fitnesstrax/app/src/main.rs index 316bfa6..bbad804 100644 --- a/fitnesstrax/app/src/main.rs +++ b/fitnesstrax/app/src/main.rs @@ -62,6 +62,9 @@ fn main() { .build(); adw_app.connect_activate(move |adw_app| { + let icon_theme = gtk::IconTheme::for_display(&gdk::Display::default().unwrap()); + icon_theme.add_resource_path(&(RESOURCE_BASE_PATH.to_owned() + "/icons/scalable/actions")); + AppWindow::new(app_id, RESOURCE_BASE_PATH, adw_app, ft_app.clone()); }); diff --git a/fitnesstrax/app/src/types.rs b/fitnesstrax/app/src/types.rs index a0614c3..8ed75fd 100644 --- a/fitnesstrax/app/src/types.rs +++ b/fitnesstrax/app/src/types.rs @@ -256,14 +256,6 @@ impl From> for DurationFormatter { } } -/* -fn take_digits(s: String) -> String { - s.chars() - .take_while(|t| t.is_ascii_digit()) - .collect::() -} -*/ - #[cfg(test)] mod test { use super::*; diff --git a/fitnesstrax/app/src/view_models/day_detail.rs b/fitnesstrax/app/src/view_models/day_detail.rs index 0f651bd..a8f3c08 100644 --- a/fitnesstrax/app/src/view_models/day_detail.rs +++ b/fitnesstrax/app/src/view_models/day_detail.rs @@ -14,10 +14,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ -#[allow(unused_imports)] -use crate::app::{ReadError, RecordProvider, WriteError}; -#[allow(unused_imports)] -use chrono::NaiveDate; +use crate::app::{ReadError, RecordProvider}; use dimensioned::si; use emseries::{Record, RecordId, Recordable}; use ft_core::{TimeDistance, TimeDistanceActivity, TraxRecord}; @@ -27,25 +24,21 @@ use std::{ sync::{Arc, RwLock}, }; +// These are actually a used imports. Clippy isn't detecting their use, probably because of complexity around the async trait macros. +#[allow(unused_imports)] +use crate::app::WriteError; +#[allow(unused_imports)] +use chrono::NaiveDate; + #[derive(Clone, Debug)] enum RecordState { Original(Record), - New(T), + New(Record), Updated(Record), Deleted(Record), } impl RecordState { - #[allow(unused)] - fn id(&self) -> Option<&RecordId> { - match self { - RecordState::Original(ref r) => Some(&r.id), - RecordState::New(ref r) => None, - RecordState::Updated(ref r) => Some(&r.id), - RecordState::Deleted(ref r) => Some(&r.id), - } - } - fn exists(&self) -> bool { match self { RecordState::Original(_) => true, @@ -55,21 +48,22 @@ impl RecordState { } } + #[allow(unused)] + fn data(&self) -> Option<&Record> { + match self { + RecordState::Original(ref r) => Some(r), + RecordState::New(ref r) => None, + RecordState::Updated(ref r) => Some(r), + RecordState::Deleted(ref r) => Some(r), + } + } + fn set_value(&mut self, value: T) { *self = match self { - RecordState::Original(r) => RecordState::Updated(Record { - id: r.id.clone(), - data: value, - }), - RecordState::New(_) => RecordState::New(value), - RecordState::Updated(r) => RecordState::Updated(Record { - id: r.id.clone(), - data: value, - }), - RecordState::Deleted(r) => RecordState::Updated(Record { - id: r.id.clone(), - data: value, - }), + RecordState::Original(r) => RecordState::Updated(Record { data: value, ..*r }), + RecordState::New(r) => RecordState::New(Record { data: value, ..*r }), + RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..*r }), + RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..*r }), }; } @@ -94,7 +88,7 @@ impl Deref for RecordState { fn deref(&self) -> &Self::Target { match self { RecordState::Original(ref r) => &r.data, - RecordState::New(ref r) => r, + RecordState::New(ref r) => &r.data, RecordState::Updated(ref r) => &r.data, RecordState::Deleted(ref r) => &r.data, } @@ -105,7 +99,7 @@ impl std::ops::DerefMut for RecordState { fn deref_mut(&mut self) -> &mut Self::Target { match self { RecordState::Original(ref mut r) => &mut r.data, - RecordState::New(ref mut r) => r, + RecordState::New(ref mut r) => &mut r.data, RecordState::Updated(ref mut r) => &mut r.data, RecordState::Deleted(ref mut r) => &mut r.data, } @@ -126,51 +120,15 @@ impl DayDetailViewModel { date: chrono::NaiveDate, provider: impl RecordProvider + 'static, ) -> Result { - let (weight_records, records): (Vec>, Vec>) = - provider - .records(date, date) - .await? - .into_iter() - .partition(|r| r.data.is_weight()); - let (step_records, records): (Vec>, Vec>) = - records.into_iter().partition(|r| r.data.is_steps()); - - if weight_records.len() > 1 { - eprintln!("warning: multiple weight records found for {}. This is unsupported and the one presented is unpredictable.", date.format("%Y-%m-%d")); - } - if step_records.len() > 1 { - eprintln!("warning: multiple step records found for {}. This is unsupported and the one presented is unpredictable.", date.format("%Y-%m-%d")); - } - - Ok(Self { + let s = Self { provider: Arc::new(provider), date, - weight: Arc::new(RwLock::new( - weight_records - .first() - .and_then(|r| match r.data { - TraxRecord::Weight(ref w) => Some((r.id.clone(), w.clone())), - _ => None, - }) - .map(|(id, w)| RecordState::Original(Record { id, data: w })), - )), - steps: Arc::new(RwLock::new( - step_records - .first() - .and_then(|r| match r.data { - TraxRecord::Steps(ref w) => Some((r.id.clone(), w.clone())), - _ => None, - }) - .map(|(id, w)| RecordState::Original(Record { id, data: w })), - )), - - records: Arc::new(RwLock::new( - records - .into_iter() - .map(|r| (r.id.clone(), RecordState::Original(r))) - .collect::>>(), - )), - }) + weight: Arc::new(RwLock::new(None)), + steps: Arc::new(RwLock::new(None)), + records: Arc::new(RwLock::new(HashMap::new())), + }; + s.populate_records().await; + Ok(s) } pub fn weight(&self) -> Option> { @@ -184,9 +142,12 @@ impl DayDetailViewModel { date: self.date, weight: new_weight, }), - None => RecordState::New(ft_core::Weight { - date: self.date, - weight: new_weight, + None => RecordState::New(Record { + id: RecordId::default(), + data: ft_core::Weight { + date: self.date, + weight: new_weight, + }, }), }; *record = Some(new_record); @@ -203,9 +164,12 @@ impl DayDetailViewModel { date: self.date, count: new_count, }), - None => RecordState::New(ft_core::Steps { - date: self.date, - count: new_count, + None => RecordState::New(Record { + id: RecordId::default(), + data: ft_core::Steps { + date: self.date, + count: new_count, + }, }), }; *record = Some(new_record); @@ -224,24 +188,10 @@ impl DayDetailViewModel { self.records .write() .unwrap() - .insert(id.clone(), RecordState::New(tr)); - println!( - "records after new_time_distance: {:?}", - self.records.read().unwrap() - ); + .insert(id, RecordState::New(Record { id, data: tr })); Record { id, data: workout } } - pub fn update_time_distance(&self, workout: Record) { - let id = workout.id.clone(); - let data = workout.data.clone(); - - let mut record_set = self.records.write().unwrap(); - record_set.entry(id).and_modify(|record_state| { - record_state.set_value(TraxRecord::TimeDistance(data)); - }); - } - pub fn time_distance_records(&self) -> Vec> { self.records .read() @@ -250,7 +200,7 @@ impl DayDetailViewModel { .filter(|(_, record)| record.exists()) .filter_map(|(id, record_state)| match **record_state { TraxRecord::TimeDistance(ref workout) => Some(Record { - id: id.clone(), + id: *id, data: workout.clone(), }), _ => None, @@ -279,11 +229,27 @@ impl DayDetailViewModel { ) } + pub fn update_record(&self, update: Record) { + let mut records = self.records.write().unwrap(); + records + .entry(update.id) + .and_modify(|record| record.set_value(update.data)); + } + + pub fn records(&self) -> Vec> { + let read_lock = self.records.read().unwrap(); + read_lock + .iter() + .filter_map(|(_, record_state)| record_state.data()) + .cloned() + .collect::>>() + } + #[allow(unused)] fn get_record(&self, id: &RecordId) -> Option> { let record_set = self.records.read().unwrap(); record_set.get(id).map(|record| Record { - id: id.clone(), + id: *id, data: (**record).clone(), }) } @@ -303,18 +269,19 @@ impl DayDetailViewModel { } pub fn save(&self) { - glib::spawn_future({ - let s = self.clone(); - async move { s.async_save().await } - }); + let s = self.clone(); + + glib::spawn_future(async move { s.async_save().await }); } pub async fn async_save(&self) { - println!("async_save"); let weight_record = self.weight.read().unwrap().clone(); match weight_record { Some(RecordState::New(data)) => { - let _ = self.provider.put_record(TraxRecord::Weight(data)).await; + let _ = self + .provider + .put_record(TraxRecord::Weight(data.data)) + .await; } Some(RecordState::Original(_)) => {} Some(RecordState::Updated(weight)) => { @@ -333,7 +300,7 @@ impl DayDetailViewModel { let steps_record = self.steps.read().unwrap().clone(); match steps_record { Some(RecordState::New(data)) => { - let _ = self.provider.put_record(TraxRecord::Steps(data)).await; + let _ = self.provider.put_record(TraxRecord::Steps(data.data)).await; } Some(RecordState::Original(_)) => {} Some(RecordState::Updated(steps)) => { @@ -361,7 +328,7 @@ impl DayDetailViewModel { println!("saving record: {:?}", record); match record { RecordState::New(data) => { - let _ = self.provider.put_record(data).await; + let _ = self.provider.put_record(data.data).await; } RecordState::Original(_) => {} RecordState::Updated(r) => { @@ -372,10 +339,41 @@ impl DayDetailViewModel { } } } + self.populate_records().await; } - pub fn revert(&self) { - unimplemented!(); + pub async fn revert(&self) { + self.populate_records().await; + } + + async fn populate_records(&self) { + let records = self.provider.records(self.date, self.date).await.unwrap(); + + let (weight_records, records): (Vec>, Vec>) = + records.into_iter().partition(|r| r.data.is_weight()); + let (step_records, records): (Vec>, Vec>) = + records.into_iter().partition(|r| r.data.is_steps()); + + *self.weight.write().unwrap() = weight_records + .first() + .and_then(|r| match r.data { + TraxRecord::Weight(ref w) => Some((r.id, w.clone())), + _ => None, + }) + .map(|(id, w)| RecordState::Original(Record { id, data: w })); + + *self.steps.write().unwrap() = step_records + .first() + .and_then(|r| match r.data { + TraxRecord::Steps(ref w) => Some((r.id, w.clone())), + _ => None, + }) + .map(|(id, w)| RecordState::Original(Record { id, data: w })); + + *self.records.write().unwrap() = records + .into_iter() + .map(|r| (r.id, RecordState::Original(r))) + .collect::>>(); } } @@ -383,7 +381,7 @@ impl DayDetailViewModel { mod test { use super::*; use async_trait::async_trait; - use chrono::{DateTime, FixedOffset, TimeZone}; + use chrono::{DateTime, FixedOffset}; use dimensioned::si; use emseries::Record; @@ -400,7 +398,7 @@ mod test { fn new(records: Vec>) -> Self { let record_map = records .into_iter() - .map(|r| (r.id.clone(), r)) + .map(|r| (r.id, r)) .collect::>>(); Self { records: Arc::new(RwLock::new(record_map)), @@ -434,26 +432,23 @@ mod test { async fn put_record(&self, record: TraxRecord) -> Result { let id = RecordId::default(); let record = Record { - id: id.clone(), + id: id, data: record, }; self.put_records.write().unwrap().push(record.clone()); - self.records.write().unwrap().insert(id.clone(), record); + self.records.write().unwrap().insert(id, record); Ok(id) } async fn update_record(&self, record: Record) -> Result<(), WriteError> { println!("updated record: {:?}", record); self.updated_records.write().unwrap().push(record.clone()); - self.records - .write() - .unwrap() - .insert(record.id.clone(), record); + self.records.write().unwrap().insert(record.id, record); Ok(()) } async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> { - self.deleted_records.write().unwrap().push(id.clone()); + self.deleted_records.write().unwrap().push(id); let _ = self.records.write().unwrap().remove(&id); Ok(()) } @@ -577,11 +572,8 @@ mod test { let mut record = view_model.new_time_distance(TimeDistanceActivity::BikeRide); record.data.duration = Some(60. * si::S); - view_model.update_time_distance(record.clone()); - let record = Record { - id: record.id, - data: TraxRecord::TimeDistance(record.data), - }; + let record = record.map(TraxRecord::TimeDistance); + view_model.update_record(record.clone()); assert_eq!(view_model.get_record(&record.id), Some(record)); assert_eq!( view_model.time_distance_summary(TimeDistanceActivity::BikeRide), @@ -603,10 +595,8 @@ mod test { let (view_model, provider) = create_view_model().await; let mut workout = view_model.time_distance_records().first().cloned().unwrap(); - println!("found record: {:?}", workout); - workout.data.duration = Some(1800. * si::S); - view_model.update_time_distance(workout.clone()); + view_model.update_record(workout.map(TraxRecord::TimeDistance)); assert_eq!( view_model.time_distance_summary(TimeDistanceActivity::BikeRide), @@ -640,7 +630,7 @@ mod test { #[tokio::test] async fn it_can_delete_an_existing_record() { let (view_model, provider) = create_view_model().await; - let mut workout = view_model.time_distance_records().first().cloned().unwrap(); + let workout = view_model.time_distance_records().first().cloned().unwrap(); view_model.remove_record(workout.id); assert_eq!( diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs index acbf44b..ead6184 100644 --- a/fitnesstrax/app/src/views/historical_view.rs +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -18,8 +18,6 @@ use crate::{ app::App, components::DaySummary, types::DayInterval, view_models::DayDetailViewModel, }; - - use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; use std::{cell::RefCell, rc::Rc}; @@ -56,34 +54,32 @@ impl ObjectSubclass for HistoricalViewPrivate { .single_click_activate(true) .build(), }; + factory.connect_bind({ let app = s.app.clone(); move |_, list_item| { - let app = app.clone(); - let list_item = list_item.clone(); - glib::spawn_future_local(async move { - let date = list_item - .downcast_ref::() - .expect("should be a ListItem") - .item() - .and_downcast::() - .expect("should be a DaySummary"); + let date = list_item + .downcast_ref::() + .expect("should be a ListItem") + .item() + .and_downcast::() + .expect("should be a Date"); - let summary = list_item - .downcast_ref::() - .expect("should be a ListItem") - .child() - .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"); - if let Some(app) = app.borrow().clone() { - glib::spawn_future_local(async move { - let view_model = - DayDetailViewModel::new(date.date(), app).await.unwrap(); - summary.set_data(view_model); - }); - } - }); + if let Some(app) = app.borrow().clone() { + glib::spawn_future_local(async move { + let view_model = DayDetailViewModel::new(date.date(), app.clone()) + .await + .unwrap(); + summary.set_data(view_model); + }); + } } }); @@ -119,9 +115,7 @@ impl HistoricalView { 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. + // This gets triggered whenever the user clicks on an item on the list. let item = s.model().unwrap().item(idx).unwrap(); let date = item.downcast_ref::().unwrap(); on_select_day(date.date()); diff --git a/fitnesstrax/core/src/types.rs b/fitnesstrax/core/src/types.rs index c50d4eb..a48da32 100644 --- a/fitnesstrax/core/src/types.rs +++ b/fitnesstrax/core/src/types.rs @@ -134,18 +134,6 @@ impl TraxRecord { }) ) } - - /* - pub fn is_time_distance_type(&self, type_: TimeDistanceActivity) -> bool { - match type_ { - TimeDistanceWorkoutType::BikeRide => matches!(self, TraxRecord::BikeRide(_)), - TimeDistanceWorkoutType::Row => matches!(self, TraxRecord::Row(_)), - TimeDistanceWorkoutType::Run => matches!(self, TraxRecord::Run(_)), - TimeDistanceWorkoutType::Swim => matches!(self, TraxRecord::Swim(_)), - TimeDistanceWorkoutType::Walk => matches!(self, TraxRecord::Walk(_)), - } - } - */ } impl Recordable for TraxRecord { diff --git a/flake.nix b/flake.nix index 91bc76d..70bc31b 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,7 @@ pkgs.gst_all_1.gstreamer pkgs.gtk4 pkgs.libadwaita + pkgs.librsvg pkgs.nodejs pkgs.openssl pkgs.pipewire diff --git a/icon-test/Cargo.toml b/icon-test/Cargo.toml new file mode 100644 index 0000000..27b7f1c --- /dev/null +++ b/icon-test/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "icon-test" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] } +gio = { version = "0.18" } +glib = { version = "0.18" } +gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] } diff --git a/icon-test/src/main.rs b/icon-test/src/main.rs new file mode 100644 index 0000000..7349f55 --- /dev/null +++ b/icon-test/src/main.rs @@ -0,0 +1,38 @@ +use adw::prelude::*; + +fn main() { + let adw_app = adw::Application::builder().build(); + + adw_app.connect_activate(move |adw_app| { + let window = gtk::ApplicationWindow::builder() + .application(adw_app) + .width_request(400) + .height_request(400) + .build(); + + let sunrise_button = gtk::Button::builder() + .icon_name("daytime-sunrise-symbolic") + .width_request(64) + .height_request(64) + .build(); + + let walking_button = gtk::Button::builder() + .icon_name("walking2-symbolic") + .width_request(64) + .height_request(64) + .build(); + + let layout = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .valign(gtk::Align::Start) + .build(); + layout.append(&sunrise_button); + layout.append(&walking_button); + + window.set_child(Some(&layout)); + window.present(); + }); + + let args: Vec = std::env::args().collect(); + ApplicationExtManual::run_with_args(&adw_app, &args); +}