diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 19ebda5..8735086 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -16,13 +16,12 @@ You should have received a copy of the GNU General Public License along with Fit // use chrono::NaiveDate; // use ft_core::TraxRecord; -use chrono::{Local, NaiveDate}; -use dimensioned::si; +use crate::components::{TimeDistanceView, WeightView}; use emseries::Record; -use ft_core::{RecordType, TimeDistance, TraxRecord, Weight}; +use ft_core::{RecordType, TraxRecord, Weight}; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; -use std::{cell::RefCell, rc::Rc}; +use std::cell::RefCell; pub struct DaySummaryPrivate { date: gtk::Label, @@ -219,215 +218,3 @@ impl DayDetail { 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/edit_view.rs b/fitnesstrax/app/src/components/edit_view.rs new file mode 100644 index 0000000..f6f96a9 --- /dev/null +++ b/fitnesstrax/app/src/components/edit_view.rs @@ -0,0 +1,22 @@ +/* +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 . +*/ + +#[derive(Clone)] +pub enum EditView { + Unconfigured, + View(View), + Edit(Edit), +} diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index c6bce7d..19ee548 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -17,6 +17,18 @@ You should have received a copy of the GNU General Public License along with Fit mod day; pub use day::{DayDetail, DaySummary}; +mod edit_view; +pub use edit_view::EditView; + +mod text_entry; +pub use text_entry::{ParseError, TextEntry}; + +mod time_distance; +pub use time_distance::TimeDistanceView; + +mod weight; +pub use weight::WeightView; + use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; use std::{cell::RefCell, path::PathBuf, rc::Rc}; diff --git a/fitnesstrax/app/src/components/text_entry.rs b/fitnesstrax/app/src/components/text_entry.rs new file mode 100644 index 0000000..2220ec9 --- /dev/null +++ b/fitnesstrax/app/src/components/text_entry.rs @@ -0,0 +1,95 @@ +/* +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 gtk::prelude::*; +use std::{cell::RefCell, rc::Rc}; + +pub struct ParseError; + +#[derive(Clone)] +pub struct TextEntry { + value: Rc>>, + widget: gtk::Entry, + renderer: Rc String>>, + validator: Rc Result>>, +} + +// I do not understand why the data should be 'static. +impl TextEntry { + pub fn new(placeholder: &str, value: Option, renderer: R, validator: V) -> Self + where + R: Fn(&T) -> String + 'static, + V: Fn(&str) -> Result + 'static, + { + let widget = gtk::Entry::builder().placeholder_text(placeholder).build(); + match value { + Some(ref v) => widget.set_text(&renderer(&v)), + None => {} + } + + let s = Self { + value: Rc::new(RefCell::new(value)), + widget, + renderer: Rc::new(Box::new(renderer)), + validator: Rc::new(Box::new(validator)), + }; + + s.widget.buffer().connect_text_notify({ + let s = s.clone(); + move |buffer| s.handle_text_change(buffer) + }); + + s + } + + fn handle_text_change(&self, buffer: >k::EntryBuffer) { + if buffer.text().is_empty() { + *self.value.borrow_mut() = None; + self.widget.remove_css_class("error"); + return; + } + match (self.validator)(buffer.text().as_str()) { + Ok(v) => { + *self.value.borrow_mut() = Some(v); + self.widget.remove_css_class("error"); + } + // need to change the border to provide a visual indicator of an error + Err(_) => { + self.widget.add_css_class("error"); + } + } + } + + pub fn value(&self) -> Option { + self.value.borrow().clone() + } + + pub fn set_value(&self, value: Option) { + match value { + Some(ref v) => self.widget.set_text(&(self.renderer)(&v)), + None => {} + } + *self.value.borrow_mut() = value; + } + + pub fn grab_focus(&self) { + self.widget.grab_focus(); + } + + pub fn widget(&self) -> gtk::Widget { + self.widget.clone().upcast::() + } +} diff --git a/fitnesstrax/app/src/components/time_distance.rs b/fitnesstrax/app/src/components/time_distance.rs new file mode 100644 index 0000000..8a20c42 --- /dev/null +++ b/fitnesstrax/app/src/components/time_distance.rs @@ -0,0 +1,107 @@ +/* +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 crate::components::{EditView, ParseError, TextEntry}; +// use chrono::{Local, NaiveDate}; +// use dimensioned::si; +use ft_core::{RecordType, TimeDistance}; +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; +use std::cell::RefCell; + +#[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/weight.rs b/fitnesstrax/app/src/components/weight.rs new file mode 100644 index 0000000..f32d11e --- /dev/null +++ b/fitnesstrax/app/src/components/weight.rs @@ -0,0 +1,186 @@ +/* +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 crate::components::{EditView, ParseError, TextEntry}; +use chrono::{Local, NaiveDate}; +use dimensioned::si; +use ft_core::Weight; +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; +use std::cell::RefCell; + +pub struct WeightViewPrivate { + date: RefCell, + record: RefCell>, + + widget: RefCell>>>, + + on_edit_finished: RefCell)>>, +} + +impl Default for WeightViewPrivate { + fn default() -> Self { + Self { + date: RefCell::new(Local::now().date_naive()), + record: RefCell::new(None), + widget: RefCell::new(EditView::Unconfigured), + 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(); + + s + } + + fn view(&self) { + let view = gtk::Label::builder() + .css_classes(["card", "weight-view"]) + .halign(gtk::Align::Start) + .can_focus(true) + .build(); + + let view_click_controller = gtk::GestureClick::new(); + view_click_controller.connect_released({ + let s = self.clone(); + move |_, _, _, _| { + s.edit(); + } + }); + + view.add_controller(view_click_controller); + + 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(EditView::View(view)); + } + + fn edit(&self) { + let edit = TextEntry::>::new( + "weight", + None, + |val: &si::Kilogram| val.to_string(), + |v: &str| v.parse::().map(|w| w * si::KG).map_err(|_| ParseError), + ); + + match *self.imp().record.borrow() { + Some(ref record) => edit.set_value(Some(record.weight)), + None => edit.set_value(None), + } + + self.swap(EditView::Edit(edit.clone())); + edit.grab_focus(); + } + + fn swap(&self, new_view: EditView>>) { + let mut widget = self.imp().widget.borrow_mut(); + match *widget { + EditView::Unconfigured => {} + EditView::View(ref view) => self.remove(view), + EditView::Edit(ref editor) => self.remove(&editor.widget()), + } + + match new_view { + EditView::Unconfigured => {} + EditView::View(ref view) => self.append(view), + EditView::Edit(ref editor) => self.append(&editor.widget()), + } + *widget = new_view; + } + + pub fn blur(&self) { + match *self.imp().widget.borrow() { + EditView::Unconfigured => {} + EditView::View(_) => {} + EditView::Edit(ref editor) => { + let weight = editor.value(); + // This has really turned into rubbish + // on_edit_finished needs to accept a full record now. + // needs to be possible to delete a record if the value is None + // it's hard to be sure whether I need the full record object or if I need to update + // it. I probably don't. I think I need to borrow it and call on_edit_finished with an + // updated version of it. + // on_edit_finished still doesn't have a way to support a delete operation + let record = match (self.imp().record.borrow().clone(), weight) { + // update an existing record + (Some(record), Some(weight)) => Some(Weight { + date: record.date, + weight, + }), + + // create a new record + (None, Some(weight)) => Some(Weight { + date: self.imp().date.borrow().clone(), + weight, + }), + + // do nothing or delete an existing record + (_, None) => None, + }; + + match record { + Some(record) => { + self.imp().on_edit_finished.borrow()(record.weight); + *self.imp().record.borrow_mut() = Some(record); + } + None => {} + } + } + } + + self.view(); + } +}