Compare commits

...

14 Commits

Author SHA1 Message Date
Savanni D'Gerinel e5e33f29f6 Clean up warnings and remove printlns 2024-01-29 23:58:15 -05:00
Savanni D'Gerinel 33c85ee7b3 Reload data when the user saves on the DayEdit panel
This required some big overhauls. The view model no longer takes records. It only takes the date that it is responsible for, and it will ask the database for records pertaining to that date. This means that once the view model has saved all of its records, it can simply reload those records from the database. This has the effect that as soon as the user moves from DayEdit back to DayDetail, all of the interesting information has been repopulated.
2024-01-29 10:22:21 -05:00
Savanni D'Gerinel 9874b6081b The view model can no longer be initialized without an app 2024-01-29 09:18:36 -05:00
Savanni D'Gerinel 7d5d639ed9 Create Duration and Distance structures to handle rendering
These structures handle parsing and rendering of a Duration and a Distance, allowing that knowledge to be centralized and reused. Then I'm using those structures in a variety of places in order to ensure that the information gets rendered consistently.
2024-01-29 08:26:41 -05:00
Savanni D'Gerinel 4fd377a3f1 Show existing time/distance workout rows in day detail and editor 2024-01-28 16:28:27 -05:00
Savanni D'Gerinel 2277055f84 Save new time/distance records
This sets up a bunch of callbacks. We're starting to get into Callback Hell, where there are things that need knowledge that I really don't want them to have.

However, edit fields for TimeDistanceEdit now propogate data back into the view model, which is then able to save the results.
2024-01-28 14:00:09 -05:00
Savanni D'Gerinel 39acfe7950 Build the facilities to add a new time/distance workout
This adds the code to show the new records in the UI, plus it adds them to the view model. Some of the representation changed in order to facilitate linking UI elements to particular records. There are now some buttons to create workouts of various types, clicking on a button adds a new row to the UI, and it also adds a new record to the view model. Saving the view model writes the records to the database.
2024-01-27 10:37:18 -05:00
Savanni D'Gerinel d0cce4ee58 Make emseries::Record copyable 2024-01-27 10:36:11 -05:00
Savanni D'Gerinel 3a716ee546 Build some convenienc functions for measurement entry fields
Move the weight field into text_entry
2024-01-27 09:13:53 -05:00
Savanni D'Gerinel 22ba4f575d Add buttons with icons to represent workouts 2024-01-25 23:11:55 -05:00
Savanni D'Gerinel 3b2130fa01 Add a test program for gnome icons 2024-01-25 23:11:55 -05:00
Savanni D'Gerinel 8f53bc4de6 Implement the Edit Cancel button 2024-01-25 23:11:55 -05:00
Savanni D'Gerinel fbe21616e3 Render time distance details in the day detail view 2024-01-25 23:11:55 -05:00
Savanni D'Gerinel bbf07ef818 Show a summary of the day's biking stats when there is one 2024-01-25 23:11:55 -05:00
27 changed files with 1037 additions and 573 deletions

11
Cargo.lock generated
View File

@ -933,6 +933,7 @@ dependencies = [
"dimensioned 0.8.0", "dimensioned 0.8.0",
"emseries", "emseries",
"ft-core", "ft-core",
"gdk4",
"gio", "gio",
"glib", "glib",
"glib-build-tools 0.18.0", "glib-build-tools 0.18.0",
@ -1794,6 +1795,16 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "icon-test"
version = "0.1.0"
dependencies = [
"gio",
"glib",
"gtk4",
"libadwaita",
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.1.5" version = "0.1.5"

View File

@ -16,6 +16,7 @@ members = [
"geo-types", "geo-types",
"gm-control-panel", "gm-control-panel",
"hex-grid", "hex-grid",
"icon-test",
"ifc", "ifc",
"kifu/core", "kifu/core",
"kifu/gtk", "kifu/gtk",

View File

@ -110,7 +110,7 @@ where
.map_err(EmseriesReadError::JSONParseError) .map_err(EmseriesReadError::JSONParseError)
.and_then(Record::try_from) .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(EmseriesReadError::RecordDeleted(id)) => records.remove(&id),
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
@ -126,7 +126,7 @@ where
pub fn put(&mut self, entry: T) -> Result<RecordId, EmseriesWriteError> { pub fn put(&mut self, entry: T) -> Result<RecordId, EmseriesWriteError> {
let uuid = RecordId::default(); let uuid = RecordId::default();
let record = Record { let record = Record {
id: uuid.clone(), id: uuid,
data: entry, data: entry,
}; };
self.update(record)?; self.update(record)?;
@ -136,7 +136,7 @@ where
/// Update an existing record. The [RecordId] of the record passed into this function must match /// Update an existing record. The [RecordId] of the record passed into this function must match
/// the [RecordId] of a record already in the database. /// the [RecordId] of a record already in the database.
pub fn update(&mut self, record: Record<T>) -> Result<(), EmseriesWriteError> { pub fn update(&mut self, record: Record<T>) -> 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 { let write_res = match serde_json::to_string(&RecordOnDisk {
id: record.id, id: record.id,
data: Some(record.data), data: Some(record.data),
@ -166,7 +166,7 @@ where
self.records.remove(uuid); self.records.remove(uuid);
let rec: RecordOnDisk<T> = RecordOnDisk { let rec: RecordOnDisk<T> = RecordOnDisk {
id: uuid.clone(), id: *uuid,
data: None, data: None,
}; };
match serde_json::to_string(&rec) { match serde_json::to_string(&rec) {

View File

@ -120,7 +120,7 @@ pub trait Recordable {
/// Uniquely identifies a record. /// Uniquely identifies a record.
/// ///
/// This is a wrapper around a basic uuid with some extra convenience methods. /// 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); pub struct RecordId(Uuid);
impl Default for RecordId { impl Default for RecordId {
@ -190,7 +190,7 @@ mod test {
impl Recordable for WeightRecord { impl Recordable for WeightRecord {
fn timestamp(&self) -> Timestamp { fn timestamp(&self) -> Timestamp {
Timestamp::Date(self.date.clone()) Timestamp::Date(self.date)
} }
fn tags(&self) -> Vec<String> { fn tags(&self) -> Vec<String> {

View File

@ -20,7 +20,7 @@ extern crate emseries;
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use chrono::{format::Fixed, prelude::*}; use chrono::{prelude::*};
use chrono_tz::Etc::UTC; use chrono_tz::Etc::UTC;
use dimensioned::si::{Kilogram, Meter, Second, M, S}; use dimensioned::si::{Kilogram, Meter, Second, M, S};
@ -42,7 +42,7 @@ mod test {
impl Recordable for BikeTrip { impl Recordable for BikeTrip {
fn timestamp(&self) -> Timestamp { fn timestamp(&self) -> Timestamp {
Timestamp::DateTime(self.datetime.clone()) Timestamp::DateTime(self.datetime)
} }
fn tags(&self) -> Vec<String> { fn tags(&self) -> Vec<String> {
Vec::new() Vec::new()
@ -99,7 +99,7 @@ mod test {
] ]
} }
fn run_test<T>(test: T) -> () fn run_test<T>(test: T)
where where
T: FnOnce(tempfile::TempPath), T: FnOnce(tempfile::TempPath),
{ {
@ -108,7 +108,7 @@ mod test {
test(tmp_path); test(tmp_path);
} }
fn run<T>(test: T) -> () fn run<T>(test: T)
where where
T: FnOnce(Series<BikeTrip>), T: FnOnce(Series<BikeTrip>),
{ {
@ -280,8 +280,7 @@ mod test {
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0) UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
.unwrap() .unwrap()
.with_timezone(&FixedOffset::east_opt(0).unwrap()), .with_timezone(&FixedOffset::east_opt(0).unwrap()),
) ),
.into(),
true, true,
), ),
|l, r| l.timestamp().cmp(&r.timestamp()), |l, r| l.timestamp().cmp(&r.timestamp()),

View File

@ -15,6 +15,7 @@ emseries = { path = "../../emseries" }
ft-core = { path = "../core" } ft-core = { path = "../core" }
gio = { version = "0.18" } gio = { version = "0.18" }
glib = { version = "0.18" } glib = { version = "0.18" }
gdk = { version = "0.7", package = "gdk4" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] } gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
thiserror = { version = "1.0" } thiserror = { version = "1.0" }
tokio = { version = "1.34", features = [ "full" ] } tokio = { version = "1.34", features = [ "full" ] }

View File

@ -3,4 +3,15 @@
<gresource prefix="/com/luminescent-dreams/fitnesstrax/"> <gresource prefix="/com/luminescent-dreams/fitnesstrax/">
<file>style.css</file> <file>style.css</file>
</gresource> </gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions/">
<file preprocess="xml-stripblanks">walking2-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">running-symbolic.svg</file>
</gresource>
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
<file preprocess="xml-stripblanks">cycling-symbolic.svg</file>
</gresource>
</gresources> </gresources>

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 2 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m 0 0"/><path d="m 4.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><path d="m 8.992188 13.007812 v -3.003906 c 0 -0.359375 -0.1875 -0.6875 -0.5 -0.867187 l -2.558594 -1.476563 l 0.363281 1.363282 l 1.671875 -2.890626 l -1.367188 0.363282 l 0.910157 0.527344 l -0.40625 -0.4375 c 0.773437 1.621093 1.96875 1.933593 1.96875 1.933593 s 0.578125 0.242188 1.9375 0.429688 c 0.546875 0.074219 1.050781 -0.304688 1.128906 -0.851563 c 0.074219 -0.550781 -0.308594 -1.054687 -0.855469 -1.128906 c -1.179687 -0.164062 -1.601562 -0.355469 -1.601562 -0.355469 s -0.425782 -0.164062 -0.769532 -0.886719 c -0.089843 -0.183593 -0.226562 -0.335937 -0.402343 -0.4375 l -0.910157 -0.523437 c -0.476562 -0.277344 -1.089843 -0.113281 -1.363281 0.367187 l -1.671875 2.890626 c -0.277344 0.480468 -0.113281 1.089843 0.367188 1.367187 l 2.558594 1.480469 l -0.5 -0.867188 v 3.003906 c 0 0.550782 0.449218 1 1 1 c 0.554687 0 1 -0.449218 1 -1 z m 0 0"/><path d="m 14.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 8.5 0 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m -2.5 4 c -0.117188 0 -0.230469 0.027344 -0.335938 0.082031 l -2 1 c -0.144531 0.070313 -0.261718 0.1875 -0.332031 0.332031 l -1 2 c -0.1875 0.371094 -0.039062 0.820313 0.332031 1.007813 c 0.371094 0.183594 0.820313 0.035156 1.003907 -0.335937 l 0.890625 -1.777344 l 1.5625 -0.773438 c -0.042969 0.074219 -0.726563 2.835938 -0.726563 2.835938 c -0.230469 0.949218 0.398438 1.523437 0.398438 1.523437 l 3.351562 2.703125 l 0.90625 2.71875 c 0.175781 0.523438 0.742188 0.808594 1.265625 0.632813 c 0.523438 -0.175781 0.808594 -0.742188 0.632813 -1.265625 l -1 -3 c -0.0625 -0.183594 -0.171875 -0.34375 -0.324219 -0.464844 l -2 -1.597656 l 0.679688 -2.714844 l 0.25 0.625 c 0.085937 0.222656 0.28125 0.390625 0.515624 0.449219 l 2 0.5 c 0.402344 0.097656 0.808594 -0.144531 0.910157 -0.546875 c 0.097656 -0.40625 -0.144531 -0.8125 -0.546875 -0.910156 l -1.628906 -0.40625 l -0.855469 -2.144532 c -0.117188 -0.285156 -0.390625 -0.472656 -0.699219 -0.472656 z m -1.164062 6.328125 l -0.710938 2.128906 l -1.832031 1.835938 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 2 -2 c 0.109375 -0.109375 0.191407 -0.242187 0.242188 -0.390625 l 0.542969 -1.628906 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 1.5 c 0 0.828125 -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 s 1.5 0.671875 1.5 1.5 z m 0 0"/><path d="m 7 4 c -0.550781 0 -1 0.449219 -1 1 v 4 c 0 0.265625 0.105469 0.519531 0.292969 0.707031 l 0.445312 0.449219 l -2.59375 4.328125 c -0.285156 0.476563 -0.132812 1.089844 0.34375 1.375 c 0.472657 0.28125 1.085938 0.128906 1.367188 -0.34375 l 2.34375 -3.902344 l 0.925781 0.929688 l 0.925781 2.773437 c 0.082031 0.25 0.265625 0.460938 0.5 0.578125 c 0.238281 0.121094 0.515625 0.140625 0.765625 0.054688 c 0.25 -0.082031 0.460938 -0.265625 0.578125 -0.5 c 0.121094 -0.238281 0.140625 -0.515625 0.054688 -0.765625 l -1 -3 c -0.050781 -0.148438 -0.132813 -0.28125 -0.242188 -0.390625 l -1.707031 -1.707031 v -4.585938 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/><path d="m 6 4 c -0.101562 0 -0.207031 0.019531 -0.300781 0.0625 c 0 0 -2.113281 0.847656 -2.199219 2.90625 v 0.03125 v 2.25 c 0 0.414062 0.335938 0.75 0.75 0.75 s 0.75 -0.335938 0.75 -0.75 v -2.21875 c 0.039062 -0.894531 1.050781 -1.449219 1.207031 -1.53125 h 2.332031 l 1.042969 2.085938 c 0.097657 0.195312 0.273438 0.339843 0.488281 0.394531 l 2 0.5 c 0.191407 0.046875 0.394532 0.015625 0.566407 -0.085938 c 0.171875 -0.101562 0.292969 -0.269531 0.34375 -0.460937 c 0.046875 -0.195313 0.015625 -0.398438 -0.085938 -0.570313 c -0.101562 -0.171875 -0.269531 -0.292969 -0.464843 -0.34375 l -1.664063 -0.414062 l -1.097656 -2.191407 c -0.125 -0.253906 -0.382813 -0.414062 -0.667969 -0.414062 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -57,6 +57,20 @@ impl App {
} }
} }
pub async fn get_record(&self, id: RecordId) -> Result<Option<Record<TraxRecord>>, 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()
}
pub async fn records( pub async fn records(
&self, &self,
start: NaiveDate, start: NaiveDate,

View File

@ -16,13 +16,13 @@ You should have received a copy of the GNU General Public License along with Fit
use crate::{ use crate::{
app::App, app::App,
types::DayInterval,
view_models::DayDetailViewModel, view_models::DayDetailViewModel,
views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView}, views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView},
}; };
use adw::prelude::*; use adw::prelude::*;
use chrono::{Duration, Local}; use chrono::{Duration, Local};
use emseries::Record;
use ft_core::TraxRecord;
use gio::resources_lookup_data; use gio::resources_lookup_data;
use gtk::STYLE_PROVIDER_PRIORITY_USER; use gtk::STYLE_PROVIDER_PRIORITY_USER;
use std::{cell::RefCell, path::PathBuf, rc::Rc}; use std::{cell::RefCell, path::PathBuf, rc::Rc};
@ -54,7 +54,7 @@ impl AppWindow {
let window = adw::ApplicationWindow::builder() let window = adw::ApplicationWindow::builder()
.application(adw_app) .application(adw_app)
.width_request(800) .width_request(800)
.height_request(600) .height_request(746)
.build(); .build();
let stylesheet = String::from_utf8( let stylesheet = String::from_utf8(
@ -99,10 +99,6 @@ impl AppWindow {
window.set_content(Some(&navigation)); window.set_content(Some(&navigation));
window.present(); window.present();
let gesture = gtk::GestureClick::new();
gesture.connect_released(|_, _, _, _| println!("detected gesture"));
layout.add_controller(gesture);
let s = Self { let s = Self {
app: ft_app, app: ft_app,
layout, layout,
@ -133,25 +129,34 @@ impl AppWindow {
self.swap_main(view); self.swap_main(view);
} }
fn show_historical_view(&self, records: Vec<Record<TraxRecord>>) { fn show_historical_view(&self, start_date: chrono::NaiveDate, end_date: chrono::NaiveDate) {
let view = View::Historical(HistoricalView::new(self.app.clone(), records, { let on_select_day = {
let s = self.clone(); let s = self.clone();
Rc::new(move |date, records| { move |date, _records| {
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); let s = s.clone();
layout.append(&adw::HeaderBar::new()); glib::spawn_future_local(async move {
// layout.append(&DayDetailView::new(date, records, s.app.clone())); let view_model = DayDetailViewModel::new(date, s.app.clone()).await;
layout.append(&DayDetailView::new(DayDetailViewModel::new( let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
date, layout.append(&adw::HeaderBar::new());
records, // layout.append(&DayDetailView::new(date, records, s.app.clone()));
s.app.clone(), layout.append(&DayDetailView::new(view_model));
))); 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) .build();
.build(); s.navigation.push(page);
s.navigation.push(page); });
}) }
})); };
let view = View::Historical(HistoricalView::new(
self.app.clone(),
DayInterval {
start: start_date,
end: end_date,
},
Rc::new(on_select_day),
));
self.swap_main(view); self.swap_main(view);
} }
@ -162,7 +167,7 @@ impl AppWindow {
let end = Local::now().date_naive(); let end = Local::now().date_naive();
let start = end - Duration::days(7); let start = end - Duration::days(7);
match s.app.records(start, end).await { match s.app.records(start, end).await {
Ok(records) => s.show_historical_view(records), Ok(_records) => s.show_historical_view(start, end),
Err(_) => s.show_welcome_view(), Err(_) => s.show_welcome_view(),
} }
} }

View File

@ -17,12 +17,16 @@ 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::{ use crate::{
components::{steps_editor, weight_editor, ActionGroup, Steps, Weight}, components::{steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, Weight},
view_models::DayDetailViewModel, view_models::DayDetailViewModel,
}; };
use emseries::{Record, RecordId};
use ft_core::{RecordType, 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, rc::Rc};
use super::{time_distance::TimeDistanceEdit, time_distance_detail};
pub struct DaySummaryPrivate { pub struct DaySummaryPrivate {
date: gtk::Label, date: gtk::Label,
@ -93,11 +97,15 @@ impl DaySummary {
.css_classes(["day-summary__weight"]) .css_classes(["day-summary__weight"])
.build(); .build();
if let Some(s) = view_model.steps() { if let Some(s) = view_model.steps() {
label.set_label(&format!("{} steps", s.to_string())); label.set_label(&format!("{} steps", s));
} }
row.append(&label); row.append(&label);
self.append(&row); self.append(&row);
let biking_summary = view_model.biking_summary();
if let Some(label) = time_distance_summary(biking_summary.0, biking_summary.1) {
self.append(&label);
}
} }
} }
@ -134,31 +142,6 @@ impl DayDetail {
.build(), .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::<WeightView>() {
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() let top_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
.build(); .build();
@ -170,51 +153,19 @@ impl DayDetail {
s.append(&top_row); s.append(&top_row);
/* let records = view_model.records();
records.into_iter().for_each(|record| { for emseries::Record { data, .. } in records {
let record_view = match record { match data {
Record { TraxRecord::BikeRide(ride) => {
data: ft_core::TraxRecord::BikeRide(record), s.append(&time_distance_detail(RecordType::BikeRide, ride))
.. }
} => Some( TraxRecord::Row(row) => s.append(&time_distance_detail(RecordType::Row, row)),
TimeDistanceView::new(ft_core::RecordType::BikeRide, record) TraxRecord::Run(run) => s.append(&time_distance_detail(RecordType::Run, run)),
.upcast::<gtk::Widget>(), TraxRecord::Swim(walk) => s.append(&time_distance_detail(RecordType::Swim, walk)),
), TraxRecord::Walk(walk) => s.append(&time_distance_detail(RecordType::Walk, walk)),
Record { _ => {}
data: ft_core::TraxRecord::Row(record),
..
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
Record {
data: ft_core::TraxRecord::Run(record),
..
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
Record {
data: ft_core::TraxRecord::Swim(record),
..
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
Record {
data: ft_core::TraxRecord::Walk(record),
..
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
_ => None,
};
if let Some(record_view) = record_view {
record_view.add_css_class("day-detail");
record_view.set_halign(gtk::Align::Start);
s.append(&record_view);
} }
}); }
*/
s s
} }
@ -222,12 +173,21 @@ impl DayDetail {
pub struct DayEditPrivate { pub struct DayEditPrivate {
on_finished: RefCell<Box<dyn Fn()>>, on_finished: RefCell<Box<dyn Fn()>>,
workout_rows: RefCell<gtk::Box>,
view_model: RefCell<Option<DayDetailViewModel>>,
} }
impl Default for DayEditPrivate { impl Default for DayEditPrivate {
fn default() -> Self { fn default() -> Self {
Self { Self {
on_finished: RefCell::new(Box::new(|| {})), on_finished: RefCell::new(Box::new(|| {})),
workout_rows: RefCell::new(
gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.build(),
),
view_model: RefCell::new(None),
} }
} }
} }
@ -255,56 +215,205 @@ impl DayEdit {
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.imp().on_finished.borrow_mut() = Box::new(on_finished);
*s.imp().view_model.borrow_mut() = Some(view_model.clone());
s.append( let workout_buttons = workout_buttons(view_model.clone(), {
&ActionGroup::builder() let s = s.clone();
.primary_action("Save", { move |workout| s.add_row(workout)
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 top_row = gtk::Box::builder() view_model
.orientation(gtk::Orientation::Horizontal) .records()
.build(); .into_iter()
top_row.append( .filter_map({
&weight_editor(view_model.weight(), { let s = s.clone();
let view_model = view_model.clone(); move |record| {
move |w| { let workout_type = record.data.workout_type();
view_model.set_weight(w); match record.data {
TraxRecord::BikeRide(workout)
| TraxRecord::Row(workout)
| TraxRecord::Run(workout)
| TraxRecord::Swim(workout)
| TraxRecord::Walk(workout) => {
Some(TimeDistanceEdit::new(workout_type, workout, {
let s = s.clone();
move |type_, data| {
s.update_workout(record.id, type_, data);
}
}))
}
_ => None,
}
} }
}) })
.widget(), .for_each(|row| s.imp().workout_rows.borrow().append(&row));
);
top_row.append( s.append(&control_buttons(&s, &view_model));
&steps_editor(view_model.steps(), { s.append(&weight_and_steps_row(&view_model));
let view_model = view_model.clone(); s.append(&*s.imp().workout_rows.borrow());
move |s| view_model.set_steps(s) s.append(&workout_buttons);
})
.widget(),
);
s.append(&top_row);
s s
} }
fn finish(&self) { 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
.clone()
.expect("DayEdit has not been initialized with the view model")
};
let _ = view_model.save().await;
(s.imp().on_finished.borrow())()
}
});
}
fn add_row(&self, workout: Record<TraxRecord>) {
let workout_rows = self.imp().workout_rows.borrow();
let workout_id = workout.id;
let workout_type = workout.data.workout_type();
match workout.data {
TraxRecord::BikeRide(ref w)
| TraxRecord::Row(ref w)
| TraxRecord::Swim(ref w)
| TraxRecord::Run(ref w)
| TraxRecord::Walk(ref w) => {
workout_rows.append(&TimeDistanceEdit::new(workout_type, w.clone(), {
let s = self.clone();
move |type_, data| s.update_workout(workout_id, type_, data)
}));
}
_ => {}
}
}
fn update_workout(&self, id: RecordId, type_: RecordType, data: ft_core::TimeDistance) {
let data = match type_ {
RecordType::BikeRide => TraxRecord::BikeRide(data),
RecordType::Row => TraxRecord::Row(data),
RecordType::Swim => TraxRecord::Swim(data),
RecordType::Run => TraxRecord::Run(data),
RecordType::Walk => TraxRecord::Walk(data),
_ => panic!("Record type {:?} is not a Time/Distance record", type_),
};
let record = Record { id, data };
let view_model = self.imp().view_model.borrow();
let view_model = view_model
.as_ref()
.expect("DayEdit has not been initialized with a view model");
view_model.update_record(record);
} }
} }
fn control_buttons(s: &DayEdit, view_model: &DayDetailViewModel) -> ActionGroup {
ActionGroup::builder()
.primary_action("Save", {
let s = s.clone();
let _view_model = view_model.clone();
move || s.finish()
})
.secondary_action("Cancel", {
let s = s.clone();
let view_model = view_model.clone();
move || {
view_model.revert();
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(), {
let view_model = view_model.clone();
move |w| match w {
Some(w) => view_model.set_weight(w),
None => unimplemented!("need to delete the weight entry"),
}
})
.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 => unimplemented!("need to delete the steps entry"),
}
})
.widget(),
);
row
}
fn workout_buttons<AddRow>(view_model: DayDetailViewModel, add_row: AddRow) -> gtk::Box
where
AddRow: Fn(Record<TraxRecord>) + '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_record(RecordType::Walk);
&add_row(workout);
}
});
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_record(RecordType::Walk);
add_row(workout);
}
});
*/
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_record(RecordType::BikeRide);
add_row(workout);
}
});
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
}

View File

@ -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 even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details. 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/>.
*/ */
mod action_group; mod action_group;
@ -27,13 +28,13 @@ mod steps;
pub use steps::{steps_editor, Steps}; pub use steps::{steps_editor, Steps};
mod text_entry; mod text_entry;
pub use text_entry::{ParseError, TextEntry}; pub use text_entry::{distance_field, duration_field, time_field, weight_field, TextEntry};
mod time_distance; mod time_distance;
pub use time_distance::TimeDistanceView; pub use time_distance::{time_distance_detail, time_distance_summary};
mod weight; mod weight;
pub use weight::{weight_editor, Weight}; pub use weight::Weight;
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};

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::components::{ParseError, TextEntry}; use crate::{components::TextEntry, types::ParseError};
use gtk::prelude::*; use gtk::prelude::*;
#[derive(Default)] #[derive(Default)]
@ -44,18 +44,13 @@ impl Steps {
pub fn steps_editor<OnUpdate>(value: Option<u32>, on_update: OnUpdate) -> TextEntry<u32> pub fn steps_editor<OnUpdate>(value: Option<u32>, on_update: OnUpdate) -> TextEntry<u32>
where where
OnUpdate: Fn(u32) + 'static, OnUpdate: Fn(Option<u32>) + 'static,
{ {
TextEntry::new( TextEntry::new(
"0", "0",
value, value,
|v| format!("{}", v), |v| format!("{}", v),
move |v| match v.parse::<u32>() { |v| v.parse::<u32>().map_err(|_| ParseError),
Ok(val) => { on_update,
on_update(val);
Ok(val)
}
Err(_) => Err(ParseError),
},
) )
} }

View File

@ -14,22 +14,20 @@ 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::{Distance, Duration, FormatOption, ParseError};
use dimensioned::si;
use gtk::prelude::*; use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
#[derive(Clone, Debug)] pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
pub struct ParseError; pub type OnUpdate<T> = dyn Fn(Option<T>);
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,
#[allow(unused)]
renderer: Rc<Renderer<T>>,
parser: Rc<Parser<T>>, parser: Rc<Parser<T>>,
on_update: Rc<OnUpdate<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> {
@ -44,10 +42,17 @@ impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> {
// I do not understand why the data should be 'static. // I do not understand why the data should be 'static.
impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> { impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
pub fn new<R, V>(placeholder: &str, value: Option<T>, renderer: R, parser: V) -> Self pub fn new<R, V, U>(
placeholder: &str,
value: Option<T>,
renderer: R,
parser: V,
on_update: U,
) -> Self
where where
R: Fn(&T) -> String + 'static, R: Fn(&T) -> String + 'static,
V: Fn(&str) -> Result<T, ParseError> + 'static, V: Fn(&str) -> Result<T, ParseError> + 'static,
U: Fn(Option<T>) + 'static,
{ {
let widget = gtk::Entry::builder().placeholder_text(placeholder).build(); let widget = gtk::Entry::builder().placeholder_text(placeholder).build();
if let Some(ref v) = value { if let Some(ref v) = value {
@ -57,8 +62,8 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
let s = Self { let s = Self {
value: Rc::new(RefCell::new(value)), value: Rc::new(RefCell::new(value)),
widget, widget,
renderer: Rc::new(renderer),
parser: Rc::new(parser), parser: Rc::new(parser),
on_update: Rc::new(on_update),
}; };
s.widget.buffer().connect_text_notify({ s.widget.buffer().connect_text_notify({
@ -77,8 +82,9 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
} }
match (self.parser)(buffer.text().as_str()) { match (self.parser)(buffer.text().as_str()) {
Ok(v) => { Ok(v) => {
*self.value.borrow_mut() = Some(v); *self.value.borrow_mut() = Some(v.clone());
self.widget.remove_css_class("error"); self.widget.remove_css_class("error");
(self.on_update)(Some(v));
} }
// need to change the border to provide a visual indicator of an error // need to change the border to provide a visual indicator of an error
Err(_) => { Err(_) => {
@ -87,25 +93,65 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
} }
} }
#[allow(unused)]
pub fn value(&self) -> Option<T> {
let v = self.value.borrow().clone();
self.value.borrow().clone()
}
pub fn set_value(&self, value: Option<T>) {
if let Some(ref v) = value {
self.widget.set_text(&(self.renderer)(v))
}
*self.value.borrow_mut() = value;
}
#[allow(unused)]
pub fn grab_focus(&self) {
self.widget.grab_focus();
}
pub fn widget(&self) -> gtk::Widget { pub fn widget(&self) -> gtk::Widget {
self.widget.clone().upcast::<gtk::Widget>() self.widget.clone().upcast::<gtk::Widget>()
} }
} }
pub fn weight_field<OnUpdate>(
weight: Option<si::Kilogram<f64>>,
on_update: OnUpdate,
) -> TextEntry<si::Kilogram<f64>>
where
OnUpdate: Fn(Option<si::Kilogram<f64>>) + 'static,
{
TextEntry::new(
"0 kg",
weight,
|val: &si::Kilogram<f64>| val.to_string(),
move |v: &str| v.parse::<f64>().map(|w| w * si::KG).map_err(|_| ParseError),
on_update,
)
}
pub fn time_field<OnUpdate>(
value: chrono::NaiveTime,
on_update: OnUpdate,
) -> TextEntry<chrono::NaiveTime>
where
OnUpdate: Fn(Option<chrono::NaiveTime>) + 'static,
{
TextEntry::new(
"hh:mm",
Some(value),
|v| v.format("%H:%M").to_string(),
|s| chrono::NaiveTime::parse_from_str(s, "%H:%M").map_err(|_| ParseError),
on_update,
)
}
pub fn distance_field<OnUpdate>(value: Option<Distance>, on_update: OnUpdate) -> TextEntry<Distance>
where
OnUpdate: Fn(Option<Distance>) + 'static,
{
TextEntry::new(
"0 km",
value,
|v| format!("{} km", v.value_unsafe / 1000.),
Distance::parse,
on_update,
)
}
pub fn duration_field<OnUpdate>(value: Option<Duration>, on_update: OnUpdate) -> TextEntry<Duration>
where
OnUpdate: Fn(Option<Duration>) + 'static,
{
TextEntry::new(
"0 minutes",
value,
|v| v.format(FormatOption::Abbreviated),
Duration::parse,
on_update,
)
}

View File

@ -17,92 +17,190 @@ You should have received a copy of the GNU General Public License along with Fit
// use crate::components::{EditView, ParseError, TextEntry}; // use crate::components::{EditView, ParseError, TextEntry};
// use chrono::{Local, NaiveDate}; // use chrono::{Local, NaiveDate};
// use dimensioned::si; // use dimensioned::si;
use crate::{
components::{distance_field, duration_field, time_field},
types::{Distance, Duration, FormatOption},
};
use dimensioned::si;
use ft_core::{RecordType, TimeDistance}; use ft_core::{RecordType, TimeDistance};
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)] pub fn time_distance_summary(distance: Distance, duration: Duration) -> Option<gtk::Label> {
pub struct TimeDistanceViewPrivate { let text = match (*distance > si::M, *duration > si::S) {
#[allow(unused)] (true, true) => Some(format!(
record: RefCell<Option<TimeDistance>>, "{} 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(type_: ft_core::RecordType, 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(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(record.datetime.format("%H:%M").to_string())
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(format!("{:?}", type_))
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.distance
.map(|dist| Distance::from(dist).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()),
)
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.duration
.map(|duration| Duration::from(duration).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()),
)
.build(),
);
layout.append(&first_row);
layout.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.comments
.map(|comments| comments.to_string())
.unwrap_or("".to_owned()),
)
.build(),
);
layout
}
pub struct TimeDistanceEditPrivate {
type_: RefCell<RecordType>,
workout: RefCell<TimeDistance>,
on_update: RefCell<Box<dyn Fn(RecordType, TimeDistance)>>,
}
impl Default for TimeDistanceEditPrivate {
fn default() -> Self {
Self {
type_: RefCell::new(RecordType::BikeRide),
workout: RefCell::new(TimeDistance::new(chrono::Utc::now().into())),
on_update: RefCell::new(Box::new(|_, _| {})),
}
}
} }
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for TimeDistanceViewPrivate { impl ObjectSubclass for TimeDistanceEditPrivate {
const NAME: &'static str = "TimeDistanceView"; const NAME: &'static str = "TimeDistanceEdit";
type Type = TimeDistanceView; type Type = TimeDistanceEdit;
type ParentType = gtk::Box; type ParentType = gtk::Box;
} }
impl ObjectImpl for TimeDistanceViewPrivate {} impl ObjectImpl for TimeDistanceEditPrivate {}
impl WidgetImpl for TimeDistanceViewPrivate {} impl WidgetImpl for TimeDistanceEditPrivate {}
impl BoxImpl for TimeDistanceViewPrivate {} impl BoxImpl for TimeDistanceEditPrivate {}
glib::wrapper! { glib::wrapper! {
pub struct TimeDistanceView(ObjectSubclass<TimeDistanceViewPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; pub struct TimeDistanceEdit(ObjectSubclass<TimeDistanceEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
} }
impl TimeDistanceView { impl Default for TimeDistanceEdit {
pub fn new(type_: RecordType, record: TimeDistance) -> 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_hexpand(true); s.set_hexpand(true);
s.set_css_classes(&["time-distance-edit"]);
let first_row = gtk::Box::builder().homogeneous(true).build();
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(record.datetime.format("%H:%M").to_string())
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(format!("{:?}", type_))
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.distance
.map(|dist| format!("{}", dist))
.unwrap_or("".to_owned()),
)
.build(),
);
first_row.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.duration
.map(|duration| format!("{}", duration))
.unwrap_or("".to_owned()),
)
.build(),
);
s.append(&first_row);
s.append(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.label(
record
.comments
.map(|comments| comments.to_string())
.unwrap_or("".to_owned()),
)
.build(),
);
s s
} }
} }
impl TimeDistanceEdit {
pub fn new<OnUpdate>(type_: RecordType, workout: TimeDistance, on_update: OnUpdate) -> Self
where
OnUpdate: Fn(ft_core::RecordType, ft_core::TimeDistance) + 'static,
{
let s = Self::default();
*s.imp().type_.borrow_mut() = type_;
*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(workout.datetime.naive_local().time(), {
let s = s.clone();
move |t| s.update_time(t)
})
.widget(),
);
details_row.append(
&distance_field(workout.distance.map(Distance::from), {
let s = s.clone();
move |d| s.update_distance(d.map(|d| *d))
})
.widget(),
);
details_row.append(
&duration_field(workout.duration.map(Duration::from), {
let s = s.clone();
move |d| s.update_duration(d.map(|d| *d))
})
.widget(),
);
s.append(&details_row);
s.append(&gtk::Entry::new());
s
}
fn update_time(&self, _time: Option<chrono::NaiveTime>) {
unimplemented!()
}
fn update_distance(&self, distance: Option<si::Meter<f64>>) {
let mut workout = self.imp().workout.borrow_mut();
workout.distance = distance;
(self.imp().on_update.borrow())(self.imp().type_.borrow().clone(), workout.clone());
}
fn update_duration(&self, duration: Option<si::Second<f64>>) {
let mut workout = self.imp().workout.borrow_mut();
workout.duration = duration;
(self.imp().on_update.borrow())(self.imp().type_.borrow().clone(), workout.clone());
}
}

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::components::{ParseError, TextEntry};
use dimensioned::si; use dimensioned::si;
use gtk::prelude::*; use gtk::prelude::*;
@ -41,27 +40,3 @@ impl Weight {
self.label.clone().upcast() self.label.clone().upcast()
} }
} }
pub fn weight_editor<OnUpdate>(
weight: Option<si::Kilogram<f64>>,
on_update: OnUpdate,
) -> TextEntry<si::Kilogram<f64>>
where
OnUpdate: Fn(si::Kilogram<f64>) + 'static,
{
TextEntry::new(
"0 kg",
weight,
|val: &si::Kilogram<f64>| val.to_string(),
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),
}
},
)
}

View File

@ -60,6 +60,9 @@ fn main() {
.build(); .build();
adw_app.connect_activate(move |adw_app| { 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()); AppWindow::new(app_id, RESOURCE_BASE_PATH, adw_app, ft_app.clone());
}); });

View File

@ -1,4 +1,8 @@
use chrono::{Duration, Local, NaiveDate}; use chrono::{Local, NaiveDate};
use dimensioned::si;
#[derive(Clone, Debug)]
pub struct ParseError;
// This interval doesn't feel right, either. The idea that I have a specific interval type for just // This interval doesn't feel right, either. The idea that I have a specific interval type for just
// NaiveDate is odd. This should be genericized, as should the iterator. Also, it shouldn't live // NaiveDate is odd. This should be genericized, as should the iterator. Also, it shouldn't live
@ -12,7 +16,7 @@ pub struct DayInterval {
impl Default for DayInterval { impl Default for DayInterval {
fn default() -> Self { fn default() -> Self {
Self { Self {
start: (Local::now() - Duration::days(7)).date_naive(), start: (Local::now() - chrono::Duration::days(7)).date_naive(),
end: Local::now().date_naive(), end: Local::now().date_naive(),
} }
} }
@ -38,10 +42,133 @@ 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; let val = self.current;
self.current += Duration::days(1); self.current += chrono::Duration::days(1);
Some(val) Some(val)
} else { } else {
None None
} }
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatOption {
Abbreviated,
Full,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct Distance {
value: si::Meter<f64>,
}
impl Distance {
pub fn format(&self, option: FormatOption) -> String {
match option {
FormatOption::Abbreviated => format!("{} km", self.value.value_unsafe / 1000.),
FormatOption::Full => format!("{} kilometers", self.value.value_unsafe / 1000.),
}
}
pub fn parse(s: &str) -> Result<Distance, ParseError> {
let digits = take_digits(s.to_owned());
let value = digits.parse::<f64>().map_err(|_| ParseError)?;
Ok(Distance {
value: value * 1000. * si::M,
})
}
}
impl std::ops::Add for Distance {
type Output = Distance;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.value + rside.value)
}
}
impl std::ops::Sub for Distance {
type Output = Distance;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.value - rside.value)
}
}
impl std::ops::Deref for Distance {
type Target = si::Meter<f64>;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl From<si::Meter<f64>> for Distance {
fn from(value: si::Meter<f64>) -> Self {
Self { value }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct Duration {
value: si::Second<f64>,
}
impl Duration {
pub fn format(&self, option: FormatOption) -> String {
let (hours, minutes) = self.hours_and_minutes();
let (h, m) = match option {
FormatOption::Abbreviated => ("h", "m"),
FormatOption::Full => ("hours", "minutes"),
};
if hours > 0 {
format!("{}{} {}{}", hours, h, minutes, m)
} else {
format!("{}{}", minutes, m)
}
}
pub fn parse(s: &str) -> Result<Duration, ParseError> {
let digits = take_digits(s.to_owned());
let value = digits.parse::<f64>().map_err(|_| ParseError)?;
Ok(Duration {
value: value * 60. * si::S,
})
}
fn hours_and_minutes(&self) -> (i64, i64) {
let minutes: i64 = (self.value.value_unsafe / 60.).round() as i64;
let hours: i64 = minutes / 60;
let minutes = minutes - (hours * 60);
(hours, minutes)
}
}
impl std::ops::Add for Duration {
type Output = Duration;
fn add(self, rside: Self) -> Self::Output {
Self::Output::from(self.value + rside.value)
}
}
impl std::ops::Sub for Duration {
type Output = Duration;
fn sub(self, rside: Self) -> Self::Output {
Self::Output::from(self.value - rside.value)
}
}
impl std::ops::Deref for Duration {
type Target = si::Second<f64>;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl From<si::Second<f64>> for Duration {
fn from(value: si::Second<f64>) -> Self {
Self { value }
}
}
fn take_digits(s: String) -> String {
s.chars()
.take_while(|t| t.is_ascii_digit())
.collect::<String>()
}

View File

@ -14,10 +14,13 @@ 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; use crate::{
app::App,
types::{Distance, Duration},
};
use dimensioned::si; use dimensioned::si;
use emseries::{Record, RecordId, Recordable}; use emseries::{Record, RecordId, Recordable};
use ft_core::TraxRecord; use ft_core::{RecordType, TimeDistance, TraxRecord};
use std::{ use std::{
collections::HashMap, collections::HashMap,
ops::Deref, ops::Deref,
@ -27,7 +30,7 @@ use std::{
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum RecordState<T: Clone + Recordable> { enum RecordState<T: Clone + Recordable> {
Original(Record<T>), Original(Record<T>),
New(T), New(Record<T>),
Updated(Record<T>), Updated(Record<T>),
#[allow(unused)] #[allow(unused)]
Deleted(Record<T>), Deleted(Record<T>),
@ -44,15 +47,29 @@ impl<T: Clone + emseries::Recordable> RecordState<T> {
} }
} }
fn with_value(self, value: T) -> RecordState<T> { fn data(&self) -> Option<&Record<T>> {
match self { match self {
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..r }), RecordState::Original(ref r) => Some(r),
RecordState::New(_) => RecordState::New(value), RecordState::New(ref _r) => None,
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..r }), RecordState::Updated(ref r) => Some(r),
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..r }), RecordState::Deleted(ref r) => Some(r),
} }
} }
fn set_value(&mut self, value: T) {
*self = match self {
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 }),
};
}
fn with_value(mut self, value: T) -> RecordState<T> {
self.set_value(value);
self
}
#[allow(unused)] #[allow(unused)]
fn with_delete(self) -> Option<RecordState<T>> { fn with_delete(self) -> Option<RecordState<T>> {
match self { match self {
@ -69,19 +86,16 @@ impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
match self { match self {
RecordState::Original(ref r) => &r.data, RecordState::Original(ref r) => &r.data,
RecordState::New(ref r) => r, RecordState::New(ref r) => &r.data,
RecordState::Updated(ref r) => &r.data, RecordState::Updated(ref r) => &r.data,
RecordState::Deleted(ref r) => &r.data, RecordState::Deleted(ref r) => &r.data,
} }
} }
} }
#[derive(Default)] #[derive(Clone)]
struct DayDetailViewModelInner {}
#[derive(Clone, Default)]
pub struct DayDetailViewModel { pub struct DayDetailViewModel {
app: Option<App>, app: App,
pub date: chrono::NaiveDate, pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>, weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>, steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
@ -89,40 +103,17 @@ pub struct DayDetailViewModel {
} }
impl DayDetailViewModel { impl DayDetailViewModel {
pub fn new(date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>, app: App) -> Self { pub async fn new(date: chrono::NaiveDate, app: App) -> Self {
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) = let s = Self {
records.into_iter().partition(|r| r.data.is_weight()); app,
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps());
Self {
app: Some(app),
date, 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( weight: Arc::new(RwLock::new(None)),
records steps: Arc::new(RwLock::new(None)),
.into_iter() records: Arc::new(RwLock::new(HashMap::new())),
.map(|r| (r.id.clone(), RecordState::Original(r))) };
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>(), s.populate_records().await;
)), s
}
} }
pub fn weight(&self) -> Option<si::Kilogram<f64>> { pub fn weight(&self) -> Option<si::Kilogram<f64>> {
@ -136,9 +127,12 @@ impl DayDetailViewModel {
date: self.date, date: self.date,
weight: new_weight, weight: new_weight,
}), }),
None => RecordState::New(ft_core::Weight { None => RecordState::New(Record {
date: self.date, id: RecordId::default(),
weight: new_weight, data: ft_core::Weight {
date: self.date,
weight: new_weight,
},
}), }),
}; };
*record = Some(new_record); *record = Some(new_record);
@ -155,81 +149,201 @@ impl DayDetailViewModel {
date: self.date, date: self.date,
count: new_count, count: new_count,
}), }),
None => RecordState::New(ft_core::Steps { None => RecordState::New(Record {
date: self.date, id: RecordId::default(),
count: new_count, data: ft_core::Steps {
date: self.date,
count: new_count,
},
}), }),
}; };
*record = Some(new_record); *record = Some(new_record);
} }
pub fn save(&self) { pub fn biking_summary(&self) -> (Distance, Duration) {
self.records.read().unwrap().iter().fold(
(Distance::default(), Duration::default()),
|(acc_distance, acc_duration), (_, record)| match record.data() {
Some(Record {
data:
TraxRecord::BikeRide(TimeDistance {
distance, duration, ..
}),
..
}) => (
distance
.map(|distance| acc_distance + Distance::from(distance))
.unwrap_or(acc_distance),
duration
.map(|duration| acc_duration + Duration::from(duration))
.unwrap_or(acc_duration),
),
_ => (acc_distance, acc_duration),
},
)
}
pub fn new_record(&self, type_: RecordType) -> Record<TraxRecord> {
let new_record = Record {
id: RecordId::default(),
data: ft_core::TraxRecord::new(type_, chrono::Local::now().into()),
};
self.records
.write()
.unwrap()
.insert(new_record.id, RecordState::New(new_record.clone()));
new_record
}
pub fn update_record(&self, update: Record<TraxRecord>) {
let mut records = self.records.write().unwrap();
records
.entry(update.id)
.and_modify(|record| record.set_value(update.data));
}
pub fn records(&self) -> Vec<Record<TraxRecord>> {
let read_lock = self.records.read().unwrap();
read_lock
.iter()
.filter_map(|(_, record_state)| record_state.data())
.cloned()
.collect::<Vec<Record<TraxRecord>>>()
}
pub fn save(&self) -> glib::JoinHandle<()> {
glib::spawn_future({ glib::spawn_future({
let s = self.clone(); let s = self.clone();
async move { async move {
if let Some(app) = s.app { let weight_record = s.weight.read().unwrap().clone();
let weight_record = s.weight.read().unwrap().clone(); match weight_record {
match weight_record { Some(RecordState::New(Record { data, .. })) => {
Some(RecordState::New(weight)) => { let _ = s.app.put_record(TraxRecord::Weight(data)).await;
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 => {}
} }
Some(RecordState::Original(_)) => {}
let steps_record = s.steps.read().unwrap().clone(); Some(RecordState::Updated(weight)) => {
match steps_record { let _ = s
Some(RecordState::New(steps)) => { .app
let _ = app.put_record(TraxRecord::Steps(steps)).await; .update_record(Record {
} id: weight.id,
Some(RecordState::Original(_)) => {} data: TraxRecord::Weight(weight.data),
Some(RecordState::Updated(steps)) => { })
let _ = app .await;
.update_record(Record {
id: steps.id,
data: TraxRecord::Steps(steps.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
} }
Some(RecordState::Deleted(_)) => {}
None => {}
}
let records = s let steps_record = s.steps.read().unwrap().clone();
.records match steps_record {
.write() Some(RecordState::New(Record { data, .. })) => {
.unwrap() let _ = s.app.put_record(TraxRecord::Steps(data)).await;
.drain() }
.map(|(_, record)| record) Some(RecordState::Original(_)) => {}
.collect::<Vec<RecordState<TraxRecord>>>(); Some(RecordState::Updated(steps)) => {
let _ = s
.app
.update_record(Record {
id: steps.id,
data: TraxRecord::Steps(steps.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
for record in records { let records = s
match record { .records
RecordState::New(data) => { .write()
let _ = app.put_record(data).await; .unwrap()
} .drain()
RecordState::Original(_) => {} .map(|(_, record)| record)
RecordState::Updated(r) => { .collect::<Vec<RecordState<TraxRecord>>>();
let _ = app.update_record(r.clone()).await;
} for record in records {
RecordState::Deleted(_) => unimplemented!(), match record {
RecordState::New(Record { data, .. }) => {
let _ = s.app.put_record(data).await;
} }
RecordState::Original(_) => {}
RecordState::Updated(r) => {
let _ = s.app.update_record(r.clone()).await;
}
RecordState::Deleted(_) => unimplemented!(),
} }
} }
s.populate_records().await;
}
})
}
pub fn revert(&self) {
glib::spawn_future({
let s = self.clone();
async move {
s.populate_records().await;
} }
}); });
} }
pub fn revert(&self) { async fn populate_records(&self) {
unimplemented!(); let records = self.app.records(self.date, self.date).await.unwrap();
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.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::<HashMap<RecordId, RecordState<TraxRecord>>>();
} }
} }
/*
struct SavedRecordIterator<'a> {
read_lock: RwLockReadGuard<'a, HashMap<RecordId, RecordState<TraxRecord>>>,
iter: Box<dyn Iterator<Item = &'a Record<TraxRecord>> + 'a>,
}
impl<'a> SavedRecordIterator<'a> {
fn new(records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>) -> Self {
let read_lock = records.read().unwrap();
let iter = read_lock
.iter()
.map(|(_, record_state)| record_state.data())
.filter_map(|r| r);
Self {
read_lock,
iter: Box::new(iter),
}
}
}
impl<'a> Iterator for SavedRecordIterator<'a> {
type Item = &'a Record<TraxRecord>;
fn next(&mut self) -> Option<Self::Item> {
None
}
}
*/

View File

@ -25,7 +25,7 @@ use std::cell::RefCell;
#[derive(Default)] #[derive(Default)]
pub struct DayDetailViewPrivate { pub struct DayDetailViewPrivate {
container: Singleton, container: Singleton,
view_model: RefCell<DayDetailViewModel>, view_model: RefCell<Option<DayDetailViewModel>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -47,7 +47,7 @@ glib::wrapper! {
impl DayDetailView { impl DayDetailView {
pub fn new(view_model: DayDetailViewModel) -> 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().view_model.borrow_mut() = Some(view_model);
s.append(&s.imp().container); s.append(&s.imp().container);
@ -57,20 +57,28 @@ impl DayDetailView {
} }
fn view(&self) { fn view(&self) {
self.imp() let view_model = self.imp().view_model.borrow();
.container let view_model = view_model
.swap(&DayDetail::new(self.imp().view_model.borrow().clone(), { .as_ref()
let s = self.clone(); .expect("DayDetailView has not been initialized with a view_model")
move || s.edit() .clone();
}));
self.imp().container.swap(&DayDetail::new(view_model, {
let s = self.clone();
move || s.edit()
}));
} }
fn edit(&self) { fn edit(&self) {
self.imp() let view_model = self.imp().view_model.borrow();
.container let view_model = view_model
.swap(&DayEdit::new(self.imp().view_model.borrow().clone(), { .as_ref()
let s = self.clone(); .expect("DayDetailView has not been initialized with a view_model")
move || s.view() .clone();
}));
self.imp().container.swap(&DayEdit::new(view_model, {
let s = self.clone();
move || s.view()
}));
} }
} }

View File

@ -17,12 +17,12 @@ You should have received a copy of the GNU General Public License along with Fit
use crate::{ use crate::{
app::App, components::DaySummary, types::DayInterval, view_models::DayDetailViewModel, app::App, components::DaySummary, types::DayInterval, view_models::DayDetailViewModel,
}; };
use chrono::NaiveDate;
use emseries::Record; use emseries::Record;
use ft_core::TraxRecord; use ft_core::TraxRecord;
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, collections::HashMap, rc::Rc}; use std::{cell::RefCell, rc::Rc};
/// The historical view will show a window into the main database. It will show some version of /// The historical view will show a window into the main database. It will show some version of
/// daily summaries, daily details, and will provide all functions the user may need for editing /// daily summaries, daily details, and will provide all functions the user may need for editing
@ -56,15 +56,16 @@ impl ObjectSubclass for HistoricalViewPrivate {
.single_click_activate(true) .single_click_activate(true)
.build(), .build(),
}; };
factory.connect_bind({ factory.connect_bind({
let app = s.app.clone(); let app = s.app.clone();
move |_, list_item| { move |_, list_item| {
let records = list_item let date = list_item
.downcast_ref::<gtk::ListItem>() .downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem") .expect("should be a ListItem")
.item() .item()
.and_downcast::<DayRecords>() .and_downcast::<Date>()
.expect("should be a DaySummary"); .expect("should be a Date");
let summary = list_item let summary = list_item
.downcast_ref::<gtk::ListItem>() .downcast_ref::<gtk::ListItem>()
@ -74,11 +75,10 @@ impl ObjectSubclass for HistoricalViewPrivate {
.expect("should be a DaySummary"); .expect("should be a DaySummary");
if let Some(app) = app.borrow().clone() { if let Some(app) = app.borrow().clone() {
summary.set_data(DayDetailViewModel::new( glib::spawn_future_local(async move {
records.date(), let view_model = DayDetailViewModel::new(date.date(), app.clone()).await;
records.records(), summary.set_data(view_model);
app.clone(), });
));
} }
} }
}); });
@ -96,11 +96,7 @@ glib::wrapper! {
} }
impl HistoricalView { impl HistoricalView {
pub fn new<SelectFn>( pub fn new<SelectFn>(app: App, interval: DayInterval, on_select_day: Rc<SelectFn>) -> Self
app: App,
records: Vec<Record<TraxRecord>>,
on_select_day: Rc<SelectFn>,
) -> Self
where where
SelectFn: Fn(chrono::NaiveDate, Vec<Record<TraxRecord>>) + 'static, SelectFn: Fn(chrono::NaiveDate, Vec<Record<TraxRecord>>) + 'static,
{ {
@ -110,11 +106,8 @@ impl HistoricalView {
*s.imp().app.borrow_mut() = Some(app); *s.imp().app.borrow_mut() = Some(app);
let grouped_records = let mut model = gio::ListStore::new::<Date>();
GroupedRecords::new((*s.imp().time_window.borrow()).clone()).with_data(records); model.extend(interval.days().map(Date::new));
let mut model = gio::ListStore::new::<DayRecords>();
model.extend(grouped_records.items());
s.imp() s.imp()
.list_view .list_view
.set_model(Some(&gtk::NoSelection::new(Some(model)))); .set_model(Some(&gtk::NoSelection::new(Some(model))));
@ -122,12 +115,10 @@ impl HistoricalView {
s.imp().list_view.connect_activate({ s.imp().list_view.connect_activate({
let on_select_day = on_select_day.clone(); let on_select_day = on_select_day.clone();
move |s, idx| { move |s, idx| {
// This gets triggered whenever the user clicks on an item on the list. What we // This gets triggered whenever the user clicks on an item on the list.
// actually want to do here is to open a modal dialog that shows all of the details of
// the day and which allows the user to edit items within that dialog.
let item = s.model().unwrap().item(idx).unwrap(); let item = s.model().unwrap().item(idx).unwrap();
let records = item.downcast_ref::<DayRecords>().unwrap(); let date = item.downcast_ref::<Date>().unwrap();
on_select_day(records.date(), records.records()); on_select_day(date.date(), vec![]);
} }
}); });
@ -136,169 +127,36 @@ impl HistoricalView {
s s
} }
pub fn set_records(&self, records: Vec<Record<TraxRecord>>) {
println!("set_records: {:?}", records);
let grouped_records =
GroupedRecords::new((self.imp().time_window.borrow()).clone()).with_data(records);
let mut model = gio::ListStore::new::<DayRecords>();
model.extend(grouped_records.items());
self.imp()
.list_view
.set_model(Some(&gtk::NoSelection::new(Some(model))));
}
pub fn time_window(&self) -> DayInterval { pub fn time_window(&self) -> DayInterval {
self.imp().time_window.borrow().clone() self.imp().time_window.borrow().clone()
} }
} }
#[derive(Default)] #[derive(Default)]
pub struct DayRecordsPrivate { pub struct DatePrivate {
date: RefCell<chrono::NaiveDate>, date: RefCell<chrono::NaiveDate>,
records: RefCell<Vec<Record<TraxRecord>>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for DayRecordsPrivate { impl ObjectSubclass for DatePrivate {
const NAME: &'static str = "DayRecords"; const NAME: &'static str = "Date";
type Type = DayRecords; type Type = Date;
} }
impl ObjectImpl for DayRecordsPrivate {} impl ObjectImpl for DatePrivate {}
glib::wrapper! { glib::wrapper! {
pub struct DayRecords(ObjectSubclass<DayRecordsPrivate>); pub struct Date(ObjectSubclass<DatePrivate>);
} }
impl DayRecords { impl Date {
pub fn new(date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>) -> Self { pub fn new(date: chrono::NaiveDate) -> Self {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
*s.imp().date.borrow_mut() = date; *s.imp().date.borrow_mut() = date;
*s.imp().records.borrow_mut() = records;
s s
} }
pub fn date(&self) -> chrono::NaiveDate { pub fn date(&self) -> chrono::NaiveDate {
*self.imp().date.borrow() *self.imp().date.borrow()
} }
pub fn records(&self) -> Vec<Record<TraxRecord>> {
self.imp().records.borrow().clone()
}
pub fn add_record(&self, record: Record<TraxRecord>) {
self.imp().records.borrow_mut().push(record);
}
}
// This isn't feeling quite right. DayRecords is a glib object, but I'm not sure that I want to
// really be passing that around. It seems not generic enough. I feel like this whole grouped
// records thing can be made more generic.
struct GroupedRecords {
interval: DayInterval,
data: HashMap<NaiveDate, DayRecords>,
}
impl GroupedRecords {
fn new(interval: DayInterval) -> Self {
let mut s = Self {
interval: interval.clone(),
data: HashMap::new(),
};
interval.days().for_each(|date| {
let _ = s.data.insert(date, DayRecords::new(date, vec![]));
});
s
}
fn with_data(mut self, records: Vec<Record<TraxRecord>>) -> Self {
records.into_iter().for_each(|record| {
self.data
.entry(record.date())
.and_modify(|entry: &mut DayRecords| (*entry).add_record(record.clone()))
.or_insert(DayRecords::new(record.date(), vec![record]));
});
self
}
fn items(&self) -> impl Iterator<Item = DayRecords> + '_ {
self.interval.days().map(|date| {
self.data
.get(&date)
.cloned()
.unwrap_or(DayRecords::new(date, vec![]))
})
}
}
#[cfg(test)]
mod test {
use super::GroupedRecords;
use crate::types::DayInterval;
use chrono::{FixedOffset, NaiveDate, TimeZone};
use dimensioned::si::{KG, M, S};
use emseries::{Record, RecordId};
use ft_core::{Steps, TimeDistance, TraxRecord, Weight};
#[test]
fn groups_records() {
let records = vec![
Record {
id: RecordId::default(),
data: TraxRecord::Steps(Steps {
date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(),
count: 1500,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(Weight {
date: NaiveDate::from_ymd_opt(2023, 10, 13).unwrap(),
weight: 85. * KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(Weight {
date: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
weight: 86. * KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::BikeRide(TimeDistance {
datetime: FixedOffset::west_opt(10 * 60 * 60)
.unwrap()
.with_ymd_and_hms(2019, 6, 15, 12, 0, 0)
.unwrap(),
distance: Some(1000. * M),
duration: Some(150. * S),
comments: Some("Test Comments".to_owned()),
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::BikeRide(TimeDistance {
datetime: FixedOffset::west_opt(10 * 60 * 60)
.unwrap()
.with_ymd_and_hms(2019, 6, 15, 23, 0, 0)
.unwrap(),
distance: Some(1000. * M),
duration: Some(150. * S),
comments: Some("Test Comments".to_owned()),
}),
},
];
let groups = GroupedRecords::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);
}
} }

View File

@ -57,6 +57,17 @@ pub struct TimeDistance {
pub comments: Option<String>, pub comments: Option<String>,
} }
impl TimeDistance {
pub fn new(time: DateTime<FixedOffset>) -> Self {
Self {
datetime: time,
distance: None,
duration: None,
comments: None,
}
}
}
/// A singular daily weight measurement. Weight changes slowly enough that it seems unlikely to /// A singular daily weight measurement. Weight changes slowly enough that it seems unlikely to
/// need to track more than a single weight in a day. /// need to track more than a single weight in a day.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -99,6 +110,18 @@ pub enum TraxRecord {
} }
impl TraxRecord { impl TraxRecord {
pub fn new(type_: RecordType, time: DateTime<FixedOffset>) -> TraxRecord {
match type_ {
RecordType::BikeRide => TraxRecord::BikeRide(TimeDistance::new(time)),
RecordType::Row => TraxRecord::Row(TimeDistance::new(time)),
RecordType::Run => TraxRecord::Run(TimeDistance::new(time)),
RecordType::Steps => unimplemented!(),
RecordType::Swim => unimplemented!(),
RecordType::Walk => TraxRecord::Walk(TimeDistance::new(time)),
RecordType::Weight => unimplemented!(),
}
}
pub fn workout_type(&self) -> RecordType { pub fn workout_type(&self) -> RecordType {
match self { match self {
TraxRecord::BikeRide(_) => RecordType::BikeRide, TraxRecord::BikeRide(_) => RecordType::BikeRide,
@ -118,6 +141,14 @@ impl TraxRecord {
pub fn is_steps(&self) -> bool { pub fn is_steps(&self) -> bool {
matches!(self, TraxRecord::Steps(_)) matches!(self, TraxRecord::Steps(_))
} }
pub fn is_bike_ride(&self) -> bool {
matches!(self, TraxRecord::BikeRide(_))
}
pub fn is_run(&self) -> bool {
matches!(self, TraxRecord::Run(_))
}
} }
impl Recordable for TraxRecord { impl Recordable for TraxRecord {

View File

@ -32,6 +32,7 @@
pkgs.gst_all_1.gstreamer pkgs.gst_all_1.gstreamer
pkgs.gtk4 pkgs.gtk4
pkgs.libadwaita pkgs.libadwaita
pkgs.librsvg
pkgs.nodejs pkgs.nodejs
pkgs.openssl pkgs.openssl
pkgs.pipewire pkgs.pipewire

12
icon-test/Cargo.toml Normal file
View File

@ -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" ] }

38
icon-test/src/main.rs Normal file
View File

@ -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<String> = std::env::args().collect();
ApplicationExtManual::run_with_args(&adw_app, &args);
}