Compare commits

...

3 Commits

Author SHA1 Message Date
Savanni D'Gerinel 9bedb7a76c Tons of linting and get tests running again 2024-01-20 15:04:46 -05:00
Savanni D'Gerinel 1fe318068b Set up a view model for the day detail view 2024-01-20 11:16:31 -05:00
Savanni D'Gerinel 18e7e4fe2f Start setting up the day detail view model
I've created the view model and added a getter function for the weight.
I'm passing the view model now to the DayDetailView, DayDetail, and
DayEdit.

I'm starting to set up the Save function for the view model, draining
all of the updated records and saving them.

None of the components yet save any updates to the view model, so
updated_records is always going to be empty until I figure that out.
2024-01-18 09:00:08 -05:00
21 changed files with 401 additions and 302 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"rust-analyzer.showUnlinkedFileNotification": false
}

View File

@ -108,7 +108,7 @@ where
Ok(line_) => { Ok(line_) => {
match serde_json::from_str::<RecordOnDisk<T>>(line_.as_ref()) match serde_json::from_str::<RecordOnDisk<T>>(line_.as_ref())
.map_err(EmseriesReadError::JSONParseError) .map_err(EmseriesReadError::JSONParseError)
.and_then(|record| Record::try_from(record)) .and_then(Record::try_from)
{ {
Ok(record) => records.insert(record.id.clone(), record.clone()), Ok(record) => records.insert(record.id.clone(), record.clone()),
Err(EmseriesReadError::RecordDeleted(id)) => records.remove(&id), Err(EmseriesReadError::RecordDeleted(id)) => records.remove(&id),

View File

@ -11,9 +11,6 @@ You should have received a copy of the GNU General Public License along with Lum
*/ */
use chrono::{DateTime, FixedOffset, NaiveDate}; use chrono::{DateTime, FixedOffset, NaiveDate};
use chrono_tz::UTC;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use std::{cmp::Ordering, fmt, io, str}; use std::{cmp::Ordering, fmt, io, str};
use thiserror::Error; use thiserror::Error;
use uuid::Uuid; use uuid::Uuid;
@ -93,33 +90,9 @@ impl str::FromStr for Timestamp {
} }
} }
/*
impl PartialEq for Timestamp {
fn eq(&self, other: &Timestamp) -> bool {
match (self, other) {
(Timestamp::DateTime(dt1), Timestamp::DateTime(dt2)) => {
dt1.with_timezone(&UTC) == dt2.with_timezone(&UTC)
}
// It's not clear to me what would make sense when I'm comparing a date and a
// timestamp. I'm going with a naive date comparison on the idea that what I'm wanting
// here is human scale, again.
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.date_naive() == *dt2,
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => *dt1 == dt2.date_naive(),
(Timestamp::Date(dt1), Timestamp::Date(dt2)) => *dt1 == *dt2,
}
}
}
*/
impl PartialOrd for Timestamp { impl PartialOrd for Timestamp {
fn partial_cmp(&self, other: &Timestamp) -> Option<Ordering> { fn partial_cmp(&self, other: &Timestamp) -> Option<Ordering> {
// Some(self.cmp(other)) Some(self.cmp(other))
match (self, other) {
(Timestamp::DateTime(dt1), Timestamp::DateTime(dt2)) => dt1.partial_cmp(dt2),
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.date_naive().partial_cmp(dt2),
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.partial_cmp(&dt2.date_naive()),
(Timestamp::Date(dt1), Timestamp::Date(dt2)) => dt1.partial_cmp(dt2),
}
} }
} }

View File

@ -14,7 +14,6 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::types::DayInterval;
use chrono::NaiveDate; use chrono::NaiveDate;
use emseries::{time_range, Record, RecordId, Series, Timestamp}; use emseries::{time_range, Record, RecordId, Series, Timestamp};
use ft_core::TraxRecord; use ft_core::TraxRecord;
@ -52,12 +51,10 @@ impl App {
.unwrap(), .unwrap(),
); );
let s = Self { Self {
runtime, runtime,
database: Arc::new(RwLock::new(database)), database: Arc::new(RwLock::new(database)),
}; }
s
} }
pub async fn records( pub async fn records(
@ -76,7 +73,7 @@ impl App {
Timestamp::Date(end), Timestamp::Date(end),
true, true,
)) ))
.map(|record| record.clone()) .cloned()
.collect::<Vec<Record<TraxRecord>>>(); .collect::<Vec<Record<TraxRecord>>>();
Ok(records) Ok(records)
} else { } else {

View File

@ -16,6 +16,7 @@ You should have received a copy of the GNU General Public License along with Fit
use crate::{ use crate::{
app::App, app::App,
view_models::DayDetailViewModel,
views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView}, views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView},
}; };
use adw::prelude::*; use adw::prelude::*;
@ -80,7 +81,7 @@ impl AppWindow {
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.build(); .build();
let initial_view = View::Placeholder(PlaceholderView::new().upcast()); let initial_view = View::Placeholder(PlaceholderView::default().upcast());
layout.append(&initial_view.widget()); layout.append(&initial_view.widget());
@ -114,9 +115,10 @@ impl AppWindow {
s.navigation.connect_popped({ s.navigation.connect_popped({
let s = s.clone(); let s = s.clone();
move |_, _| match *s.current_view.borrow() { move |_, _| {
View::Historical(_) => s.load_records(), if let View::Historical(_) = *s.current_view.borrow() {
_ => {} s.load_records();
}
} }
}); });
@ -137,7 +139,12 @@ impl AppWindow {
Rc::new(move |date, records| { Rc::new(move |date, records| {
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
layout.append(&adw::HeaderBar::new()); layout.append(&adw::HeaderBar::new());
layout.append(&DayDetailView::new(date, records, s.app.clone())); // layout.append(&DayDetailView::new(date, records, s.app.clone()));
layout.append(&DayDetailView::new(DayDetailViewModel::new(
date,
records,
s.app.clone(),
)));
let page = &adw::NavigationPage::builder() let page = &adw::NavigationPage::builder()
.title(date.format("%Y-%m-%d").to_string()) .title(date.format("%Y-%m-%d").to_string())
.child(&layout) .child(&layout)
@ -185,22 +192,4 @@ impl AppWindow {
} }
}); });
} }
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<TraxRecord>) {
glib::spawn_future_local({
let s = self.clone();
async move {
s.app.update_record(record).await;
}
});
}
} }

View File

@ -14,6 +14,8 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
//! ActionGroup and related structures
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};

View File

@ -16,11 +16,14 @@ You should have received a copy of the GNU General Public License along with Fit
// use chrono::NaiveDate; // use chrono::NaiveDate;
// use ft_core::TraxRecord; // use ft_core::TraxRecord;
use crate::components::{ActionGroup, TimeDistanceView, Weight}; use crate::{
components::{ActionGroup, Weight},
view_models::DayDetailViewModel,
};
use emseries::Record; use emseries::Record;
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc}; use std::cell::RefCell;
use super::weight::WeightEdit; use super::weight::WeightEdit;
@ -57,8 +60,8 @@ glib::wrapper! {
pub struct DaySummary(ObjectSubclass<DaySummaryPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; pub struct DaySummary(ObjectSubclass<DaySummaryPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
} }
impl DaySummary { impl Default for DaySummary {
pub fn new() -> Self { fn default() -> Self {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical); s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["day-summary"]); s.set_css_classes(&["day-summary"]);
@ -67,6 +70,12 @@ impl DaySummary {
s s
} }
}
impl DaySummary {
pub fn new() -> Self {
Self::default()
}
pub fn set_data(&self, date: chrono::NaiveDate, records: Vec<Record<ft_core::TraxRecord>>) { pub fn set_data(&self, date: chrono::NaiveDate, records: Vec<Record<ft_core::TraxRecord>>) {
self.imp() self.imp()
@ -80,11 +89,11 @@ impl DaySummary {
if let Some(Record { if let Some(Record {
data: ft_core::TraxRecord::Weight(weight_record), data: ft_core::TraxRecord::Weight(weight_record),
.. ..
}) = records.iter().filter(|f| f.data.is_weight()).next() }) = records.iter().find(|f| f.data.is_weight())
{ {
let label = gtk::Label::builder() let label = gtk::Label::builder()
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.label(&format!("{}", weight_record.weight)) .label(weight_record.weight.to_string())
.css_classes(["day-summary__weight"]) .css_classes(["day-summary__weight"])
.build(); .build();
self.append(&label); self.append(&label);
@ -102,27 +111,14 @@ impl DaySummary {
} }
} }
pub struct DayDetailPrivate { #[derive(Default)]
date: gtk::Label, pub struct DayDetailPrivate {}
weight: RefCell<Option<gtk::Label>>,
}
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for DayDetailPrivate { impl ObjectSubclass for DayDetailPrivate {
const NAME: &'static str = "DayDetail"; const NAME: &'static str = "DayDetail";
type Type = DayDetail; type Type = DayDetail;
type ParentType = gtk::Box; 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 ObjectImpl for DayDetailPrivate {}
@ -134,11 +130,7 @@ glib::wrapper! {
} }
impl DayDetail { impl DayDetail {
pub fn new<OnEdit>( pub fn new<OnEdit>(view_model: DayDetailViewModel, on_edit: OnEdit) -> Self
date: chrono::NaiveDate,
records: Vec<Record<ft_core::TraxRecord>>,
on_edit: OnEdit,
) -> Self
where where
OnEdit: Fn() + 'static, OnEdit: Fn() + 'static,
{ {
@ -167,6 +159,7 @@ impl DayDetail {
s.add_controller(click_controller); s.add_controller(click_controller);
*/ */
/*
let weight_record = records.iter().find_map(|record| match record { let weight_record = records.iter().find_map(|record| match record {
Record { Record {
id, id,
@ -174,13 +167,12 @@ impl DayDetail {
} => Some((id.clone(), record.clone())), } => Some((id.clone(), record.clone())),
_ => None, _ => None,
}); });
*/
let weight_view = match weight_record { let weight_view = Weight::new(view_model.weight());
Some((id, data)) => Weight::new(Some(data.clone())),
None => Weight::new(None),
};
s.append(&weight_view.widget()); s.append(&weight_view.widget());
/*
records.into_iter().for_each(|record| { records.into_iter().for_each(|record| {
let record_view = match record { let record_view = match record {
Record { Record {
@ -224,21 +216,20 @@ impl DayDetail {
s.append(&record_view); s.append(&record_view);
} }
}); });
*/
s s
} }
} }
pub struct DayEditPrivate { pub struct DayEditPrivate {
date: gtk::Label, on_finished: RefCell<Box<dyn Fn()>>,
weight: Rc<WeightEdit>,
} }
impl Default for DayEditPrivate { impl Default for DayEditPrivate {
fn default() -> Self { fn default() -> Self {
Self { Self {
date: gtk::Label::new(None), on_finished: RefCell::new(Box::new(|| {})),
weight: Rc::new(WeightEdit::new(None)),
} }
} }
} }
@ -259,75 +250,51 @@ glib::wrapper! {
} }
impl DayEdit { impl DayEdit {
pub fn new<PutRecordFn, UpdateRecordFn, CancelFn>( pub fn new<OnFinished>(view_model: DayDetailViewModel, on_finished: OnFinished) -> Self
date: chrono::NaiveDate,
records: Vec<Record<ft_core::TraxRecord>>,
on_put_record: PutRecordFn,
on_update_record: UpdateRecordFn,
on_cancel: CancelFn,
) -> Self
where where
PutRecordFn: Fn(ft_core::TraxRecord) + 'static, OnFinished: Fn() + 'static,
UpdateRecordFn: Fn(Record<ft_core::TraxRecord>) + 'static,
CancelFn: Fn() + 'static,
{ {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical); s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true); s.set_hexpand(true);
*s.imp().on_finished.borrow_mut() = Box::new(on_finished);
s.append( s.append(
&ActionGroup::builder() &ActionGroup::builder()
.primary_action("Save", { .primary_action("Save", {
let s = s.clone(); let s = s.clone();
let records = records.clone(); let view_model = view_model.clone();
move || { move || {
let weight_record = records.iter().find_map(|record| match record { view_model.save();
Record { s.finish();
id, }
data: ft_core::TraxRecord::Weight(w), })
} => Some((id, w)), .secondary_action("Cancel", {
_ => None, let s = s.clone();
}); let view_model = view_model.clone();
move || {
let weight = s.imp().weight.value(); view_model.revert();
s.finish();
if let Some(weight) = weight {
match weight_record {
Some((id, _)) => on_update_record(Record {
id: id.clone(),
data: ft_core::TraxRecord::Weight(ft_core::Weight {
date,
weight,
}),
}),
None => {
on_put_record(ft_core::TraxRecord::Weight(ft_core::Weight {
date,
weight,
}))
}
}
};
} }
}) })
.secondary_action("Cancel", on_cancel)
.build(), .build(),
); );
let weight_record = records.iter().find_map(|record| match record { s.append(
Record { &WeightEdit::new(view_model.weight(), {
id, let view_model = view_model.clone();
data: ft_core::TraxRecord::Weight(record), move |w| {
} => Some((id.clone(), record.clone())), view_model.set_weight(w);
_ => None, }
}); })
.widget(),
match weight_record { );
Some((_id, data)) => s.imp().weight.set_value(Some(data.weight)),
None => s.imp().weight.set_value(None),
};
s.append(&s.imp().weight.widget());
s s
} }
fn finish(&self) {
(self.imp().on_finished.borrow())()
}
} }

View File

@ -20,9 +20,6 @@ pub use action_group::ActionGroup;
mod day; mod day;
pub use day::{DayDetail, DayEdit, DaySummary}; pub use day::{DayDetail, DayEdit, DaySummary};
mod edit_view;
pub use edit_view::EditView;
mod singleton; mod singleton;
pub use singleton::{Singleton, SingletonImpl}; pub use singleton::{Singleton, SingletonImpl};

View File

@ -17,14 +17,19 @@ You should have received a copy of the GNU General Public License along with Fit
use gtk::prelude::*; use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
#[derive(Clone, Debug)]
pub struct ParseError; pub struct ParseError;
type Renderer<T> = dyn Fn(&T) -> String;
type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
#[derive(Clone)] #[derive(Clone)]
pub struct TextEntry<T: Clone + std::fmt::Debug> { pub struct TextEntry<T: Clone + std::fmt::Debug> {
value: Rc<RefCell<Option<T>>>, value: Rc<RefCell<Option<T>>>,
widget: gtk::Entry, widget: gtk::Entry,
renderer: Rc<Box<dyn Fn(&T) -> String>>, #[allow(unused)]
parser: Rc<Box<dyn Fn(&str) -> Result<T, ParseError>>>, renderer: Rc<Renderer<T>>,
parser: Rc<Parser<T>>,
} }
impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> { impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> {
@ -45,16 +50,15 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
V: Fn(&str) -> Result<T, ParseError> + 'static, V: Fn(&str) -> Result<T, ParseError> + 'static,
{ {
let widget = gtk::Entry::builder().placeholder_text(placeholder).build(); let widget = gtk::Entry::builder().placeholder_text(placeholder).build();
match value { if let Some(ref v) = value {
Some(ref v) => widget.set_text(&renderer(&v)), widget.set_text(&renderer(v))
None => {}
} }
let s = Self { let s = Self {
value: Rc::new(RefCell::new(value)), value: Rc::new(RefCell::new(value)),
widget, widget,
renderer: Rc::new(Box::new(renderer)), renderer: Rc::new(renderer),
parser: Rc::new(Box::new(parser)), parser: Rc::new(parser),
}; };
s.widget.buffer().connect_text_notify({ s.widget.buffer().connect_text_notify({
@ -83,19 +87,20 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
} }
} }
#[allow(unused)]
pub fn value(&self) -> Option<T> { pub fn value(&self) -> Option<T> {
let v = self.value.borrow().clone(); let v = self.value.borrow().clone();
self.value.borrow().clone() self.value.borrow().clone()
} }
pub fn set_value(&self, value: Option<T>) { pub fn set_value(&self, value: Option<T>) {
match value { if let Some(ref v) = value {
Some(ref v) => self.widget.set_text(&(self.renderer)(&v)), self.widget.set_text(&(self.renderer)(v))
None => {}
} }
*self.value.borrow_mut() = value; *self.value.borrow_mut() = value;
} }
#[allow(unused)]
pub fn grab_focus(&self) { pub fn grab_focus(&self) {
self.widget.grab_focus(); self.widget.grab_focus();
} }

View File

@ -24,6 +24,7 @@ use std::cell::RefCell;
#[derive(Default)] #[derive(Default)]
pub struct TimeDistanceViewPrivate { pub struct TimeDistanceViewPrivate {
#[allow(unused)]
record: RefCell<Option<TimeDistance>>, record: RefCell<Option<TimeDistance>>,
} }
@ -53,7 +54,7 @@ impl TimeDistanceView {
first_row.append( first_row.append(
&gtk::Label::builder() &gtk::Label::builder()
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.label(&record.datetime.format("%H:%M").to_string()) .label(record.datetime.format("%H:%M").to_string())
.build(), .build(),
); );
@ -96,7 +97,7 @@ impl TimeDistanceView {
.label( .label(
record record
.comments .comments
.map(|comments| format!("{}", comments)) .map(|comments| comments.to_string())
.unwrap_or("".to_owned()), .unwrap_or("".to_owned()),
) )
.build(), .build(),

View File

@ -14,12 +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 <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::components::{EditView, ParseError, Singleton, TextEntry}; use crate::components::{ParseError, TextEntry};
use chrono::{Local, NaiveDate};
use dimensioned::si; use dimensioned::si;
use glib::{object::ObjectRef, Object}; use gtk::prelude::*;
use gtk::{prelude::*, subclass::prelude::*};
use std::{borrow::Borrow, cell::RefCell};
#[derive(Default)] #[derive(Default)]
pub struct WeightViewPrivate {} pub struct WeightViewPrivate {}
@ -29,14 +26,14 @@ pub struct Weight {
} }
impl Weight { impl Weight {
pub fn new(weight: Option<ft_core::Weight>) -> Self { pub fn new(weight: Option<si::Kilogram<f64>>) -> Self {
let label = gtk::Label::builder() let label = gtk::Label::builder()
.css_classes(["card", "weight-view"]) .css_classes(["card", "weight-view"])
.can_focus(true) .can_focus(true)
.build(); .build();
match weight { match weight {
Some(w) => label.set_text(&format!("{:?}", w.weight)), Some(w) => label.set_text(&format!("{:?}", w)),
None => label.set_text("No weight recorded"), None => label.set_text("No weight recorded"),
} }
@ -54,17 +51,30 @@ pub struct WeightEdit {
} }
impl WeightEdit { impl WeightEdit {
pub fn new(weight: Option<ft_core::Weight>) -> Self { pub fn new<OnUpdate>(weight: Option<si::Kilogram<f64>>, on_update: OnUpdate) -> Self
where
OnUpdate: Fn(si::Kilogram<f64>) + 'static,
{
Self { Self {
entry: TextEntry::new( entry: TextEntry::new(
"0 kg", "0 kg",
weight.map(|w| w.weight), weight,
|val: &si::Kilogram<f64>| val.to_string(), |val: &si::Kilogram<f64>| val.to_string(),
|v: &str| v.parse::<f64>().map(|w| w * si::KG).map_err(|_| ParseError), move |v: &str| {
let new_weight = v.parse::<f64>().map(|w| w * si::KG).map_err(|_| ParseError);
match new_weight {
Ok(w) => {
on_update(w);
Ok(w)
}
Err(err) => Err(err),
}
},
), ),
} }
} }
#[allow(unused)]
pub fn set_value(&self, value: Option<si::Kilogram<f64>>) { pub fn set_value(&self, value: Option<si::Kilogram<f64>>) {
self.entry.set_value(value); self.entry.set_value(value);
} }

View File

@ -18,12 +18,12 @@ mod app;
mod app_window; mod app_window;
mod components; mod components;
mod types; mod types;
mod view_models;
mod views; mod views;
use adw::prelude::*; use adw::prelude::*;
use app_window::AppWindow; use app_window::AppWindow;
use std::{env, path::PathBuf}; use std::{env, path::PathBuf};
use types::DayInterval;
const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev"; const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev";
const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax"; const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax";

View File

@ -21,8 +21,8 @@ impl Default for DayInterval {
impl DayInterval { impl DayInterval {
pub fn days(&self) -> impl Iterator<Item = NaiveDate> { pub fn days(&self) -> impl Iterator<Item = NaiveDate> {
DayIterator { DayIterator {
current: self.start.clone(), current: self.start,
end: self.end.clone(), end: self.end,
} }
} }
} }
@ -37,7 +37,7 @@ impl Iterator for DayIterator {
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
if self.current <= self.end { if self.current <= self.end {
let val = self.current.clone(); let val = self.current;
self.current += Duration::days(1); self.current += Duration::days(1);
Some(val) Some(val)
} else { } else {

View File

@ -0,0 +1,217 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
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 <https://www.gnu.org/licenses/>.
*/
use crate::app::App;
use dimensioned::si;
use emseries::{Record, RecordId, Recordable};
use ft_core::TraxRecord;
use std::{
collections::HashMap,
ops::Deref,
sync::{Arc, RwLock},
};
#[derive(Clone, Debug)]
enum RecordState<T: Clone + Recordable> {
Original(Record<T>),
New(T),
Updated(Record<T>),
#[allow(unused)]
Deleted(Record<T>),
}
impl<T: Clone + emseries::Recordable> RecordState<T> {
#[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 with_value(self, value: T) -> RecordState<T> {
match self {
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..r }),
RecordState::New(_) => RecordState::New(value),
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..r }),
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..r }),
}
}
#[allow(unused)]
fn with_delete(self) -> Option<RecordState<T>> {
match self {
RecordState::Original(r) => Some(RecordState::Deleted(r)),
RecordState::New(r) => None,
RecordState::Updated(r) => Some(RecordState::Deleted(r)),
RecordState::Deleted(r) => Some(RecordState::Deleted(r)),
}
}
}
impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
match self {
RecordState::Original(ref r) => &r.data,
RecordState::New(ref r) => r,
RecordState::Updated(ref r) => &r.data,
RecordState::Deleted(ref r) => &r.data,
}
}
}
#[derive(Default)]
struct DayDetailViewModelInner {}
#[derive(Clone, Default)]
pub struct DayDetailViewModel {
app: Option<App>,
pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>,
}
impl DayDetailViewModel {
pub fn new(date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>, app: App) -> Self {
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_weight());
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps());
Self {
app: Some(app),
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::<HashMap<RecordId, RecordState<TraxRecord>>>(),
)),
}
}
pub fn weight(&self) -> Option<si::Kilogram<f64>> {
(*self.weight.read().unwrap()).as_ref().map(|w| w.weight)
}
pub fn set_weight(&self, new_weight: si::Kilogram<f64>) {
let mut record = self.weight.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
date: self.date,
weight: new_weight,
}),
None => RecordState::New(ft_core::Weight {
date: self.date,
weight: new_weight,
}),
};
*record = Some(new_record);
}
pub fn steps(&self) -> Option<u32> {
(*self.steps.read().unwrap()).as_ref().map(|w| w.count)
}
pub fn set_steps(&self, new_count: u32) {
let mut record = self.steps.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Steps {
date: self.date,
count: new_count,
}),
None => RecordState::New(ft_core::Steps {
date: self.date,
count: new_count,
}),
};
*record = Some(new_record);
}
pub fn save(&self) {
glib::spawn_future({
let s = self.clone();
async move {
if let Some(app) = s.app {
let weight_record = s.weight.read().unwrap().clone();
match weight_record {
Some(RecordState::New(weight)) => {
let _ = app.put_record(TraxRecord::Weight(weight)).await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(weight)) => {
let _ = app
.update_record(Record {
id: weight.id,
data: TraxRecord::Weight(weight.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let records = s
.records
.write()
.unwrap()
.drain()
.map(|(_, record)| record)
.collect::<Vec<RecordState<TraxRecord>>>();
for record in records {
match record {
RecordState::New(data) => {
let _ = app.put_record(data).await;
}
RecordState::Original(_) => {}
RecordState::Updated(r) => {
let _ = app.update_record(r.clone()).await;
}
RecordState::Deleted(_) => unimplemented!(),
}
}
}
}
});
}
pub fn revert(&self) {
unimplemented!();
}
}

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com> Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax. This file is part of FitnessTrax.
@ -14,9 +14,5 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
#[derive(Clone)] mod day_detail;
pub enum EditView<View, Edit> { pub use day_detail::DayDetailViewModel;
Unconfigured,
View(View),
Edit(Edit),
}

View File

@ -15,21 +15,17 @@ You should have received a copy of the GNU General Public License along with Fit
*/ */
use crate::{ use crate::{
app::App,
components::{DayDetail, DayEdit, Singleton, SingletonImpl}, components::{DayDetail, DayEdit, Singleton, SingletonImpl},
view_models::DayDetailViewModel,
}; };
use emseries::Record;
use ft_core::TraxRecord;
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell; use std::cell::RefCell;
#[derive(Default)] #[derive(Default)]
pub struct DayDetailViewPrivate { pub struct DayDetailViewPrivate {
app: RefCell<Option<App>>,
container: Singleton, container: Singleton,
date: RefCell<chrono::NaiveDate>, view_model: RefCell<DayDetailViewModel>,
records: RefCell<Vec<Record<TraxRecord>>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -49,116 +45,32 @@ glib::wrapper! {
} }
impl DayDetailView { impl DayDetailView {
pub fn new(date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>, app: App) -> Self { pub fn new(view_model: DayDetailViewModel) -> Self {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
*s.imp().view_model.borrow_mut() = view_model;
*s.imp().date.borrow_mut() = date;
*s.imp().records.borrow_mut() = records;
*s.imp().app.borrow_mut() = Some(app);
s.append(&s.imp().container); s.append(&s.imp().container);
/*
s.imp()
.container
.swap(&DayDetail::new(date, records.clone(), {
let s = s.clone();
let records = records.clone();
move || {
s.imp().container.swap(&DayEdit::new(
date,
records,
s.on_put_record(),
// s.on_update_record(),
|_| {},
))
}
}));
*/
s.view(); s.view();
s s
} }
fn view(&self) { fn view(&self) {
self.imp().container.swap(&DayDetail::new( self.imp()
self.imp().date.borrow().clone(), .container
self.imp().records.borrow().clone(), .swap(&DayDetail::new(self.imp().view_model.borrow().clone(), {
{
let s = self.clone(); let s = self.clone();
move || s.edit() move || s.edit()
}, }));
));
} }
fn edit(&self) { fn edit(&self) {
self.imp().container.swap(&DayEdit::new( self.imp()
self.imp().date.borrow().clone(), .container
self.imp().records.borrow().clone(), .swap(&DayEdit::new(self.imp().view_model.borrow().clone(), {
self.on_put_record(),
self.on_update_record(),
{
let s = self.clone(); let s = self.clone();
move || s.view() move || s.view()
}, }));
));
}
fn on_put_record(&self) -> Box<dyn Fn(ft_core::TraxRecord)> {
let s = self.clone();
let app = self.imp().app.clone();
Box::new(move |record| {
let s = s.clone();
let app = app.clone();
glib::spawn_future_local({
async move {
match &*app.borrow() {
Some(app) => {
let id = app
.put_record(record.clone())
.await
.expect("successful write");
s.imp()
.records
.borrow_mut()
.push(Record { id, data: record });
}
None => {}
}
s.view();
}
});
})
}
fn on_update_record(&self) -> Box<dyn Fn(Record<ft_core::TraxRecord>)> {
let s = self.clone();
let app = self.imp().app.clone();
Box::new(move |updated_record| {
let app = app.clone();
let mut records = s.imp().records.borrow_mut();
let idx = records.iter().position(|r| r.id == updated_record.id);
match idx {
Some(i) => records[i] = updated_record.clone(),
None => records.push(updated_record.clone()),
}
glib::spawn_future_local({
let s = s.clone();
async move {
match &*app.borrow() {
Some(app) => {
let _ = app.update_record(updated_record).await;
}
None => {
println!("no app!");
}
}
s.view();
}
});
})
} }
} }

View File

@ -15,7 +15,7 @@ You should have received a copy of the GNU General Public License along with Fit
*/ */
use crate::{components::DaySummary, types::DayInterval}; use crate::{components::DaySummary, types::DayInterval};
use chrono::{Duration, Local, NaiveDate}; use chrono::NaiveDate;
use emseries::Record; use emseries::Record;
use ft_core::TraxRecord; use ft_core::TraxRecord;
use glib::Object; use glib::Object;
@ -161,7 +161,7 @@ impl DayRecords {
} }
pub fn date(&self) -> chrono::NaiveDate { pub fn date(&self) -> chrono::NaiveDate {
self.imp().date.borrow().clone() *self.imp().date.borrow()
} }
pub fn records(&self) -> Vec<Record<TraxRecord>> { pub fn records(&self) -> Vec<Record<TraxRecord>> {
@ -204,11 +204,11 @@ impl GroupedRecords {
self self
} }
fn items<'a>(&'a self) -> impl Iterator<Item = DayRecords> + 'a { fn items(&self) -> impl Iterator<Item = DayRecords> + '_ {
self.interval.days().map(|date| { self.interval.days().map(|date| {
self.data self.data
.get(&date) .get(&date)
.map(|rec| rec.clone()) .cloned()
.unwrap_or(DayRecords::new(date, vec![])) .unwrap_or(DayRecords::new(date, vec![]))
}) })
} }
@ -217,6 +217,7 @@ impl GroupedRecords {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::GroupedRecords; use super::GroupedRecords;
use crate::types::DayInterval;
use chrono::{FixedOffset, NaiveDate, TimeZone}; use chrono::{FixedOffset, NaiveDate, TimeZone};
use dimensioned::si::{KG, M, S}; use dimensioned::si::{KG, M, S};
use emseries::{Record, RecordId}; use emseries::{Record, RecordId};
@ -272,7 +273,12 @@ mod test {
}, },
]; ];
let groups = GroupedRecords::from(records).0; let groups = GroupedRecords::new(DayInterval {
start: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
end: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
})
.with_data(records)
.data;
assert_eq!(groups.len(), 3); assert_eq!(groups.len(), 3);
} }
} }

View File

@ -38,8 +38,8 @@ glib::wrapper! {
pub struct PlaceholderView(ObjectSubclass<PlaceholderViewPrivate>) @extends gtk::Box, gtk::Widget; pub struct PlaceholderView(ObjectSubclass<PlaceholderViewPrivate>) @extends gtk::Box, gtk::Widget;
} }
impl PlaceholderView { impl Default for PlaceholderView {
pub fn new() -> Self { fn default() -> Self {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
s s
} }

View File

@ -14,7 +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 <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::{app::App, components::FileChooserRow}; use crate::components::FileChooserRow;
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};
use std::path::PathBuf; use std::path::PathBuf;

View File

@ -1,23 +1,25 @@
use crate::types;
#[cfg(test)] #[cfg(test)]
mod test { mod test {
#[test] #[test]
#[ignore]
fn read_a_legacy_set_rep_record() { fn read_a_legacy_set_rep_record() {
unimplemented!() unimplemented!()
} }
#[test] #[test]
#[ignore]
fn read_a_legacy_steps_record() { fn read_a_legacy_steps_record() {
unimplemented!() unimplemented!()
} }
#[test] #[test]
#[ignore]
fn read_a_legacy_time_distance_record() { fn read_a_legacy_time_distance_record() {
unimplemented!() unimplemented!()
} }
#[test] #[test]
#[ignore]
fn read_a_legacy_weight_record() { fn read_a_legacy_weight_record() {
unimplemented!() unimplemented!()
} }

View File

@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
/// SetRep represents workouts like pushups or situps, which involve doing a "set" of a number of /// SetRep represents workouts like pushups or situps, which involve doing a "set" of a number of
/// actions, resting, and then doing another set. /// actions, resting, and then doing another set.
#[allow(dead_code)]
pub struct SetRep { pub struct SetRep {
/// I assume that a set/rep workout is only done once in a day. /// I assume that a set/rep workout is only done once in a day.
date: NaiveDate, date: NaiveDate,
@ -22,6 +23,16 @@ pub struct Steps {
pub count: u32, pub count: u32,
} }
impl Recordable for Steps {
fn timestamp(&self) -> Timestamp {
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
/// TimeDistance represents workouts characterized by a duration and a distance travelled. These /// TimeDistance represents workouts characterized by a duration and a distance travelled. These
/// sorts of workouts can occur many times a day, depending on how one records things. I might /// sorts of workouts can occur many times a day, depending on how one records things. I might
/// record a single 30-km workout if I go on a long-distanec ride. Or I might record multiple 5km /// record a single 30-km workout if I go on a long-distanec ride. Or I might record multiple 5km
@ -54,6 +65,16 @@ pub struct Weight {
pub weight: si::Kilogram<f64>, pub weight: si::Kilogram<f64>,
} }
impl Recordable for Weight {
fn timestamp(&self) -> Timestamp {
Timestamp::Date(self.date)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum RecordType { pub enum RecordType {
BikeRide, BikeRide,
@ -91,23 +112,24 @@ impl TraxRecord {
} }
pub fn is_weight(&self) -> bool { pub fn is_weight(&self) -> bool {
match self { matches!(self, TraxRecord::Weight(_))
TraxRecord::Weight(_) => true, }
_ => false,
} pub fn is_steps(&self) -> bool {
matches!(self, TraxRecord::Steps(_))
} }
} }
impl Recordable for TraxRecord { impl Recordable for TraxRecord {
fn timestamp(&self) -> Timestamp { fn timestamp(&self) -> Timestamp {
match self { match self {
TraxRecord::BikeRide(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::BikeRide(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Row(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::Row(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Run(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::Run(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Steps(rec) => Timestamp::Date(rec.date), TraxRecord::Steps(rec) => rec.timestamp(),
TraxRecord::Swim(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::Swim(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Walk(rec) => Timestamp::DateTime(rec.datetime.clone()), TraxRecord::Walk(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Weight(rec) => Timestamp::Date(rec.date), TraxRecord::Weight(rec) => rec.timestamp(),
} }
} }
@ -135,6 +157,6 @@ mod test {
let id = series.put(record.clone()).unwrap(); let id = series.put(record.clone()).unwrap();
let record_ = series.get(&id).unwrap(); let record_ = series.get(&id).unwrap();
assert_eq!(record_, record); assert_eq!(record_.data, record);
} }
} }