Compare commits

...

24 Commits

Author SHA1 Message Date
Savanni D'Gerinel ea867812bc Clean up warnings and remove printlns 2024-02-08 11:07:59 -05:00
Savanni D'Gerinel cb55d6aae0 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-02-08 11:01:46 -05:00
Savanni D'Gerinel cfc7df5e2f The view model can no longer be initialized without an app 2024-02-08 10:32:57 -05:00
Savanni D'Gerinel 9fc9d2b758 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-02-08 10:30:14 -05:00
Savanni D'Gerinel 76f4b31466 Show existing time/distance workout rows in day detail and editor 2024-02-08 10:23:47 -05:00
Savanni D'Gerinel 73052a0694 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-02-08 10:20:57 -05:00
Savanni D'Gerinel 2c42c35dfe 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-02-08 09:44:58 -05:00
Savanni D'Gerinel afe693fe10 Make emseries::Record copyable 2024-02-08 09:18:55 -05:00
Savanni D'Gerinel af1422d523 Build some convenienc functions for measurement entry fields
Move the weight field into text_entry
2024-02-08 09:18:55 -05:00
Savanni D'Gerinel 792e20d44b Add buttons with icons to represent workouts 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 8016188b29 Add a test program for gnome icons 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 74df2880bb Implement the Edit Cancel button 2024-02-08 09:13:54 -05:00
Savanni D'Gerinel 96c4201680 Render time distance details in the day detail view 2024-02-08 09:02:09 -05:00
Savanni D'Gerinel ecdd38ebbc Show a summary of the day's biking stats when there is one 2024-02-08 08:57:17 -05:00
Savanni D'Gerinel 2fb8728856 Invert the TraxRecord
This simplifies, though not as much as I was hoping, the patterns for accessing data along strict type patterns. I may see better results once I'm getting the Time/Distance views working.
2024-02-08 00:20:40 -05:00
Savanni D'Gerinel a7d43ef184 Update Cargo.nix 2024-02-08 00:16:27 -05:00
Savanni D'Gerinel 9727d35116 Resolve clippy warnings
Warnings were mounting up. It was time to resolve them before attempting a massive rebase.
2024-02-07 23:36:03 -05:00
Savanni D'Gerinel 1d6155d9e5 Finish the update and delete view model functions 2024-02-07 09:29:08 -05:00
Savanni D'Gerinel a8bf540517 Remove test that steps and weights are honored correctly
Step and weight records may be presented in any order. Any test that
tries to enforce that one gets presented before teh other can't cannot
succeed. So, I've removed that test and instead put in a warning that
will appear when the view model gets loaded.
2024-02-07 08:28:38 -05:00
Savanni D'Gerinel 3db870d790 Set up time distance operations and tests 2024-02-03 15:28:33 -05:00
Savanni D'Gerinel 24276d172b Introduce the RecordProvider interface
DayDetailViewModel needs testing. I've worked out an improved API, and a set of tests to go along with it, and those can be made more easily with a mockable RecordProvider. So, in addition to stubbing out a bunch of tests, I've also created RecordProvider, mocked it, and implemented it for App.
2024-02-01 10:12:35 -05:00
Savanni D'Gerinel 96317f5692 Finish removing the previous record grouping
Now that the DayDetailViewModel knows to retrieve its own records, the grouping functions, and passing groups of records around, no longer make sens.
2024-02-01 10:08:18 -05:00
Savanni D'Gerinel c1e797f3ae DayDetailViewModel now ignores records and directly retrieves data from App
This is preparatory work. Having the view model directly retrieve data both adds a degree of symmetry (it both gets data from and sends data to the app) and makes it possible for the view model to refresh itself when needing to revert data or after saving data.
2024-02-01 09:27:40 -05:00
Savanni D'Gerinel 304008c674 The view model can no longer be initialized without an app 2024-01-31 09:51:17 -05:00
30 changed files with 1331 additions and 644 deletions

61
Cargo.lock generated
View File

@ -133,6 +133,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-trait"
version = "0.1.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@ -420,7 +431,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -736,7 +747,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -928,11 +939,13 @@ name = "fitnesstrax"
version = "0.3.0" version = "0.3.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"async-trait",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"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",
@ -1132,7 +1145,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -1367,7 +1380,7 @@ dependencies = [
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -1794,6 +1807,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"
@ -2443,7 +2466,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -2648,7 +2671,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -2792,9 +2815,9 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.70" version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -2836,9 +2859,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.33" version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -3321,7 +3344,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -3720,9 +3743,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.41" version = "2.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3818,7 +3841,7 @@ checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -3942,7 +3965,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -4063,7 +4086,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]
@ -4433,7 +4456,7 @@ dependencies = [
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -4467,7 +4490,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -4708,7 +4731,7 @@ checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.41", "syn 2.0.48",
] ]
[[package]] [[package]]

View File

@ -655,6 +655,32 @@ rec {
}; };
resolvedDefaultFeatures = [ "default" "std" ]; resolvedDefaultFeatures = [ "default" "std" ];
}; };
"async-trait" = rec {
crateName = "async-trait";
version = "0.1.77";
edition = "2021";
sha256 = "1adf1jh2yg39rkpmqjqyr9xyd6849p0d95425i6imgbhx0syx069";
procMacro = true;
authors = [
"David Tolnay <dtolnay@gmail.com>"
];
dependencies = [
{
name = "proc-macro2";
packageId = "proc-macro2";
}
{
name = "quote";
packageId = "quote";
}
{
name = "syn";
packageId = "syn 2.0.48";
features = [ "full" "visit-mut" ];
}
];
};
"atoi" = rec { "atoi" = rec {
crateName = "atoi"; crateName = "atoi";
version = "2.0.0"; version = "2.0.0";
@ -1487,7 +1513,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "full" ]; features = [ "full" ];
} }
]; ];
@ -2394,7 +2420,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
} }
]; ];
features = { features = {
@ -2972,6 +2998,10 @@ rec {
name = "async-channel"; name = "async-channel";
packageId = "async-channel"; packageId = "async-channel";
} }
{
name = "async-trait";
packageId = "async-trait";
}
{ {
name = "chrono"; name = "chrono";
packageId = "chrono"; packageId = "chrono";
@ -3576,7 +3606,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "full" ]; features = [ "full" ];
} }
]; ];
@ -4341,7 +4371,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "full" ]; features = [ "full" ];
} }
]; ];
@ -7690,7 +7720,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "full" ]; features = [ "full" ];
} }
]; ];
@ -8187,7 +8217,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "full" "visit-mut" ]; features = [ "full" "visit-mut" ];
} }
]; ];
@ -8529,9 +8559,9 @@ rec {
}; };
"proc-macro2" = rec { "proc-macro2" = rec {
crateName = "proc-macro2"; crateName = "proc-macro2";
version = "1.0.70"; version = "1.0.78";
edition = "2021"; edition = "2021";
sha256 = "0fzxg3dkrjy101vv5b6llc8mh74xz1vhhsaiwrn68kzvynxqy9rr"; sha256 = "1bjak27pqdn4f4ih1c9nr3manzyavsgqmf76ygw9k76q8pb2lhp2";
authors = [ authors = [
"David Tolnay <dtolnay@gmail.com>" "David Tolnay <dtolnay@gmail.com>"
"Alex Crichton <alex@alexcrichton.com>" "Alex Crichton <alex@alexcrichton.com>"
@ -8665,9 +8695,9 @@ rec {
}; };
"quote" = rec { "quote" = rec {
crateName = "quote"; crateName = "quote";
version = "1.0.33"; version = "1.0.35";
edition = "2018"; edition = "2018";
sha256 = "1biw54hbbr12wdwjac55z1m2x2rylciw83qnjn564a3096jgqrsj"; sha256 = "1vv8r2ncaz4pqdr78x7f138ka595sp2ncr1sa2plm4zxbsmwj7i9";
authors = [ authors = [
"David Tolnay <dtolnay@gmail.com>" "David Tolnay <dtolnay@gmail.com>"
]; ];
@ -10269,7 +10299,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
} }
]; ];
features = { features = {
@ -11748,11 +11778,11 @@ rec {
}; };
resolvedDefaultFeatures = [ "clone-impls" "default" "derive" "extra-traits" "full" "parsing" "printing" "proc-macro" "quote" ]; resolvedDefaultFeatures = [ "clone-impls" "default" "derive" "extra-traits" "full" "parsing" "printing" "proc-macro" "quote" ];
}; };
"syn 2.0.41" = rec { "syn 2.0.48" = rec {
crateName = "syn"; crateName = "syn";
version = "2.0.41"; version = "2.0.48";
edition = "2021"; edition = "2021";
sha256 = "0sg2lzkwbwbm229p3kx1yxai43hkc0s1wmk6g47bzhvw8y6b5j24"; sha256 = "0gqgfygmrxmp8q32lia9p294kdd501ybn6kn2h4gqza0irik2d8g";
authors = [ authors = [
"David Tolnay <dtolnay@gmail.com>" "David Tolnay <dtolnay@gmail.com>"
]; ];
@ -11990,7 +12020,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
} }
]; ];
@ -12406,7 +12436,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "full" ]; features = [ "full" ];
} }
]; ];
@ -12820,7 +12850,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
usesDefaultFeatures = false; usesDefaultFeatures = false;
features = [ "full" "parsing" "printing" "visit-mut" "clone-impls" "extra-traits" "proc-macro" ]; features = [ "full" "parsing" "printing" "visit-mut" "clone-impls" "extra-traits" "proc-macro" ];
} }
@ -13814,7 +13844,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "full" ]; features = [ "full" ];
} }
{ {
@ -13904,7 +13934,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
features = [ "visit" "full" ]; features = [ "visit" "full" ];
} }
{ {
@ -15388,7 +15418,7 @@ rec {
} }
{ {
name = "syn"; name = "syn";
packageId = "syn 2.0.41"; packageId = "syn 2.0.48";
} }
]; ];

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),
}; };
@ -124,19 +124,16 @@ where
/// Put a new record into the database. A unique id will be assigned to the record and /// Put a new record into the database. A unique id will be assigned to the record and
/// returned. /// returned.
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 id = RecordId::default();
let record = Record { let record = Record { id, data: entry };
id: uuid.clone(),
data: entry,
};
self.update(record)?; self.update(record)?;
Ok(uuid) Ok(id)
} }
/// 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 +163,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 {
@ -166,6 +166,17 @@ impl<T: Clone + Recordable> Record<T> {
pub fn timestamp(&self) -> Timestamp { pub fn timestamp(&self) -> Timestamp {
self.data.timestamp() self.data.timestamp()
} }
pub fn map<Map, U>(self, map: Map) -> Record<U>
where
Map: Fn(T) -> U,
U: Clone + Recordable,
{
Record {
id: self.id,
data: map(self.data),
}
}
} }
#[cfg(test)] #[cfg(test)]
@ -190,7 +201,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

@ -8,6 +8,7 @@ edition = "2021"
[dependencies] [dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] } adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
async-channel = { version = "2.1" } async-channel = { version = "2.1" }
async-trait = { version = "0.1" }
chrono = { version = "0.4" } chrono = { version = "0.4" }
chrono-tz = { version = "0.8" } chrono-tz = { version = "0.8" }
dimensioned = { version = "0.8", features = [ "serde" ] } dimensioned = { version = "0.8", features = [ "serde" ] }
@ -15,6 +16,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

@ -14,6 +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 async_trait::async_trait;
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;
@ -34,6 +35,32 @@ pub enum AppError {
Unhandled, Unhandled,
} }
#[derive(Debug, Error)]
pub enum ReadError {
#[error("no database loaded")]
NoDatabase,
}
#[derive(Debug, Error)]
pub enum WriteError {
#[error("no database loaded")]
NoDatabase,
#[error("unhandled error")]
Unhandled,
}
#[async_trait]
pub trait RecordProvider: Send + Sync {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError>;
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError>;
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError>;
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>;
}
/// The real, headless application. This is where all of the logic will reside. /// The real, headless application. This is where all of the logic will reside.
#[derive(Clone)] #[derive(Clone)]
pub struct App { pub struct App {
@ -57,11 +84,40 @@ impl App {
} }
} }
pub async fn records( pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> {
let db_ref = self.database.clone();
self.runtime
.spawn_blocking(move || {
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
*db_ref.write().unwrap() = Some(db);
Ok(())
})
.await
.unwrap()
}
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()
}
}
#[async_trait]
impl RecordProvider for App {
async fn records(
&self, &self,
start: NaiveDate, start: NaiveDate,
end: NaiveDate, end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, AppError> { ) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let db = self.database.clone(); let db = self.database.clone();
self.runtime self.runtime
.spawn_blocking(move || { .spawn_blocking(move || {
@ -77,14 +133,14 @@ impl App {
.collect::<Vec<Record<TraxRecord>>>(); .collect::<Vec<Record<TraxRecord>>>();
Ok(records) Ok(records)
} else { } else {
Err(AppError::NoDatabase) Err(ReadError::NoDatabase)
} }
}) })
.await .await
.unwrap() .unwrap()
} }
pub async fn put_record(&self, record: TraxRecord) -> Result<RecordId, AppError> { async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let db = self.database.clone(); let db = self.database.clone();
self.runtime self.runtime
.spawn_blocking(move || { .spawn_blocking(move || {
@ -97,10 +153,10 @@ impl App {
}) })
.await .await
.unwrap() .unwrap()
.map_err(|_| AppError::Unhandled) .map_err(|_| WriteError::Unhandled)
} }
pub async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), AppError> { async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
let db = self.database.clone(); let db = self.database.clone();
self.runtime self.runtime
.spawn_blocking(move || { .spawn_blocking(move || {
@ -112,18 +168,10 @@ impl App {
}) })
.await .await
.unwrap() .unwrap()
.map_err(|_| AppError::Unhandled) .map_err(|_| WriteError::Unhandled)
} }
pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> { async fn delete_record(&self, _id: RecordId) -> Result<(), WriteError> {
let db_ref = self.database.clone(); unimplemented!()
self.runtime
.spawn_blocking(move || {
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
*db_ref.write().unwrap() = Some(db);
Ok(())
})
.await
.unwrap()
} }
} }

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,
@ -125,6 +121,7 @@ impl AppWindow {
s s
} }
#[allow(unused)]
fn show_welcome_view(&self) { fn show_welcome_view(&self) {
let view = View::Welcome(WelcomeView::new({ let view = View::Welcome(WelcomeView::new({
let s = self.clone(); let s = self.clone();
@ -133,25 +130,31 @@ impl AppWindow {
self.swap_main(view); self.swap_main(view);
} }
fn show_historical_view(&self, records: Vec<Record<TraxRecord>>) { fn show_historical_view(&self, interval: DayInterval) {
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| {
let s = s.clone();
glib::spawn_future_local(async move {
let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap();
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); 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( layout.append(&DayDetailView::new(view_model));
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)
.build(); .build();
s.navigation.push(page); s.navigation.push(page);
}) });
})); }
};
let view = View::Historical(HistoricalView::new(
self.app.clone(),
interval,
Rc::new(on_select_day),
));
self.swap_main(view); self.swap_main(view);
} }
@ -161,10 +164,7 @@ impl AppWindow {
async move { async move {
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 { s.show_historical_view(DayInterval { start, end });
Ok(records) => s.show_historical_view(records),
Err(_) => s.show_welcome_view(),
}
} }
}); });
} }
@ -181,6 +181,7 @@ impl AppWindow {
self.layout.append(&current_widget.widget()); self.layout.append(&current_widget.widget());
} }
#[allow(unused)]
fn on_apply_config(&self, path: PathBuf) { fn on_apply_config(&self, path: PathBuf) {
glib::spawn_future_local({ glib::spawn_future_local({
let s = self.clone(); let s = self.clone();

View File

@ -17,12 +17,19 @@ 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_field, ActionGroup, Steps, WeightLabel}, components::{
steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, WeightLabel,
},
types::{DistanceFormatter, DurationFormatter, WeightFormatter},
view_models::DayDetailViewModel, view_models::DayDetailViewModel,
}; };
use emseries::{Record, RecordId};
use ft_core::{TimeDistanceActivity, 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,
@ -96,8 +103,15 @@ impl DaySummary {
label.set_label(&format!("{} steps", s)); label.set_label(&format!("{} steps", s));
} }
row.append(&label); row.append(&label);
self.append(&row); self.append(&row);
let biking_summary = view_model.time_distance_summary(TimeDistanceActivity::BikeRide);
if let Some(label) = time_distance_summary(
DistanceFormatter::from(biking_summary.0),
DurationFormatter::from(biking_summary.1),
) {
self.append(&label);
}
} }
} }
@ -134,35 +148,10 @@ 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();
let weight_view = WeightLabel::new(view_model.weight()); let weight_view = WeightLabel::new(view_model.weight().map(WeightFormatter::from));
top_row.append(&weight_view.widget()); top_row.append(&weight_view.widget());
let steps_view = Steps::new(view_model.steps()); let steps_view = Steps::new(view_model.steps());
@ -170,51 +159,10 @@ impl DayDetail {
s.append(&top_row); s.append(&top_row);
/* let records = view_model.time_distance_records();
records.into_iter().for_each(|record| { for emseries::Record { data, .. } in records {
let record_view = match record { s.append(&time_distance_detail(data));
Record {
data: ft_core::TraxRecord::BikeRide(record),
..
} => Some(
TimeDistanceView::new(ft_core::RecordType::BikeRide, record)
.upcast::<gtk::Widget>(),
),
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 +170,22 @@ impl DayDetail {
pub struct DayEditPrivate { pub struct DayEditPrivate {
on_finished: RefCell<Box<dyn Fn()>>, on_finished: RefCell<Box<dyn Fn()>>,
#[allow(unused)]
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,45 +213,121 @@ 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();
move |workout| s.add_row(workout)
});
view_model
.records()
.into_iter()
.filter_map({
let s = s.clone();
move |record| match record.data {
TraxRecord::TimeDistance(workout) => Some(TimeDistanceEdit::new(workout, {
let s = s.clone();
move |data| {
s.update_workout(record.id, data);
}
})),
_ => None,
}
})
.for_each(|row| s.imp().workout_rows.borrow().append(&row));
s.append(&control_buttons(&s, &view_model));
s.append(&weight_and_steps_row(&view_model));
s.append(&*s.imp().workout_rows.borrow());
s.append(&workout_buttons);
s
}
fn finish(&self) {
glib::spawn_future_local({
let s = self.clone();
async move {
let view_model = {
let view_model = s.imp().view_model.borrow();
view_model
.as_ref()
.expect("DayEdit has not been initialized with the view model")
.clone()
};
let _ = view_model.async_save().await;
(s.imp().on_finished.borrow())()
}
});
}
fn add_row(&self, workout: Record<TraxRecord>) {
let workout_rows = self.imp().workout_rows.borrow();
#[allow(clippy::single_match)]
match workout.data {
TraxRecord::TimeDistance(r) => workout_rows.append(&TimeDistanceEdit::new(r, {
let s = self.clone();
move |data| {
println!("update workout callback on workout: {:?}", workout.id);
s.update_workout(workout.id, data)
}
})),
_ => {}
}
}
fn update_workout(&self, id: RecordId, data: ft_core::TimeDistance) {
if let Some(ref view_model) = *self.imp().view_model.borrow() {
let record = Record {
id,
data: TraxRecord::TimeDistance(data),
};
view_model.update_record(record);
}
}
}
fn control_buttons(s: &DayEdit, view_model: &DayDetailViewModel) -> ActionGroup {
ActionGroup::builder()
.primary_action("Save", { .primary_action("Save", {
let s = s.clone(); let s = s.clone();
let view_model = view_model.clone(); move || s.finish()
move || {
view_model.save();
s.finish();
}
}) })
.secondary_action("Cancel", { .secondary_action("Cancel", {
let s = s.clone(); let s = s.clone();
let view_model = view_model.clone(); let view_model = view_model.clone();
move || { move || {
view_model.revert(); let s = s.clone();
let view_model = view_model.clone();
glib::spawn_future_local(async move {
view_model.revert().await;
s.finish(); s.finish();
});
} }
}) })
.build(), .build()
); }
let top_row = gtk::Box::builder() fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box {
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
.build(); .build();
top_row.append( row.append(
&weight_field(view_model.weight(), { &weight_field(view_model.weight().map(WeightFormatter::from), {
let view_model = view_model.clone(); let view_model = view_model.clone();
move |w| match w { move |w| match w {
Some(w) => view_model.set_weight(w), Some(w) => view_model.set_weight(*w),
None => eprintln!("have not implemented record delete"), None => eprintln!("have not implemented record delete"),
} }
}) })
.widget(), .widget(),
); );
top_row.append( row.append(
&steps_editor(view_model.steps(), { &steps_editor(view_model.steps(), {
let view_model = view_model.clone(); let view_model = view_model.clone();
move |s| match s { move |s| match s {
@ -303,12 +337,65 @@ impl DayEdit {
}) })
.widget(), .widget(),
); );
s.append(&top_row);
s row
} }
fn finish(&self) { fn workout_buttons<AddRow>(view_model: DayDetailViewModel, add_row: AddRow) -> gtk::Box
(self.imp().on_finished.borrow())() 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_time_distance(TimeDistanceActivity::Walking);
add_row(workout.map(TraxRecord::TimeDistance));
} }
});
let running_button = gtk::Button::builder()
.icon_name("running-symbolic")
.width_request(64)
.height_request(64)
.build();
running_button.connect_clicked({
let view_model = view_model.clone();
move |_| {
let workout = view_model.new_time_distance(TimeDistanceActivity::Running);
add_row(workout.map(TraxRecord::TimeDistance));
}
});
*/
let biking_button = gtk::Button::builder()
.icon_name("cycling-symbolic")
.width_request(64)
.height_request(64)
.build();
biking_button.connect_clicked({
let view_model = view_model.clone();
move |_| {
let workout = view_model.new_time_distance(TimeDistanceActivity::BikeRide);
add_row(workout.map(TraxRecord::TimeDistance));
}
});
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
row.append(&biking_button);
layout.append(&row);
layout
} }

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,10 +28,10 @@ mod steps;
pub use steps::{steps_editor, Steps}; pub use steps::{steps_editor, Steps};
mod text_entry; mod text_entry;
pub use text_entry::{weight_field, TextEntry}; pub use text_entry::{distance_field, duration_field, time_field, weight_field, TextEntry};
mod time_distance; 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::WeightLabel; pub use weight::WeightLabel;

View File

@ -50,7 +50,7 @@ where
"0", "0",
value, value,
|v| format!("{}", v), |v| format!("{}", v),
move |v| v.parse::<u32>().map_err(|_| ParseError), |v| v.parse::<u32>().map_err(|_| ParseError),
on_update, on_update,
) )
} }

View File

@ -20,8 +20,8 @@ use crate::types::{
use gtk::prelude::*; use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>; pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
type OnUpdate<T> = dyn Fn(Option<T>); pub type OnUpdate<T> = dyn Fn(Option<T>);
#[derive(Clone)] #[derive(Clone)]
pub struct TextEntry<T: Clone + std::fmt::Debug> { pub struct TextEntry<T: Clone + std::fmt::Debug> {
@ -106,6 +106,7 @@ impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
} }
} }
#[allow(unused)]
pub fn time_field<OnUpdate>( pub fn time_field<OnUpdate>(
value: Option<TimeFormatter>, value: Option<TimeFormatter>,
on_update: OnUpdate, on_update: OnUpdate,
@ -122,6 +123,7 @@ where
) )
} }
#[allow(unused)]
pub fn distance_field<OnUpdate>( pub fn distance_field<OnUpdate>(
value: Option<DistanceFormatter>, value: Option<DistanceFormatter>,
on_update: OnUpdate, on_update: OnUpdate,
@ -138,6 +140,7 @@ where
) )
} }
#[allow(unused)]
pub fn duration_field<OnUpdate>( pub fn duration_field<OnUpdate>(
value: Option<DurationFormatter>, value: Option<DurationFormatter>,
on_update: OnUpdate, on_update: OnUpdate,

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com> Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax. This file is part of FitnessTrax.
@ -17,38 +17,39 @@ 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 ft_core::{RecordType, TimeDistance}; use crate::{
components::{distance_field, duration_field, time_field},
types::{DistanceFormatter, DurationFormatter, FormatOption, TimeFormatter},
};
use dimensioned::si;
use ft_core::{TimeDistance, TimeDistanceActivity};
use glib::Object; use glib::Object;
use gtk::{prelude::*, subclass::prelude::*}; use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell; use std::{rc::Rc, cell::RefCell};
#[derive(Default)] pub fn time_distance_summary(
pub struct TimeDistanceViewPrivate { distance: DistanceFormatter,
#[allow(unused)] duration: DurationFormatter,
record: RefCell<Option<TimeDistance>>, ) -> Option<gtk::Label> {
let text = match (*distance > si::M, *duration > si::S) {
(true, true) => Some(format!(
"{} of biking in {}",
distance.format(FormatOption::Full),
duration.format(FormatOption::Full)
)),
(true, false) => Some(format!("{} of biking", distance.format(FormatOption::Full))),
(false, true) => Some(format!("{} of biking", duration.format(FormatOption::Full))),
(false, false) => None,
};
text.map(|text| gtk::Label::new(Some(&text)))
} }
#[glib::object_subclass] pub fn time_distance_detail(record: ft_core::TimeDistance) -> gtk::Box {
impl ObjectSubclass for TimeDistanceViewPrivate { let layout = gtk::Box::builder()
const NAME: &'static str = "TimeDistanceView"; .orientation(gtk::Orientation::Vertical)
type Type = TimeDistanceView; .hexpand(true)
type ParentType = gtk::Box; .build();
}
impl ObjectImpl for TimeDistanceViewPrivate {}
impl WidgetImpl for TimeDistanceViewPrivate {}
impl BoxImpl for TimeDistanceViewPrivate {}
glib::wrapper! {
pub struct TimeDistanceView(ObjectSubclass<TimeDistanceViewPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl TimeDistanceView {
pub fn new(type_: RecordType, record: TimeDistance) -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
let first_row = gtk::Box::builder().homogeneous(true).build(); let first_row = gtk::Box::builder().homogeneous(true).build();
first_row.append( first_row.append(
@ -61,7 +62,7 @@ impl TimeDistanceView {
first_row.append( first_row.append(
&gtk::Label::builder() &gtk::Label::builder()
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.label(format!("{:?}", type_)) .label(format!("{:?}", record.activity))
.build(), .build(),
); );
@ -71,7 +72,7 @@ impl TimeDistanceView {
.label( .label(
record record
.distance .distance
.map(|dist| format!("{}", dist)) .map(|dist| DistanceFormatter::from(dist).format(FormatOption::Abbreviated))
.unwrap_or("".to_owned()), .unwrap_or("".to_owned()),
) )
.build(), .build(),
@ -83,15 +84,17 @@ impl TimeDistanceView {
.label( .label(
record record
.duration .duration
.map(|duration| format!("{}", duration)) .map(|duration| {
DurationFormatter::from(duration).format(FormatOption::Abbreviated)
})
.unwrap_or("".to_owned()), .unwrap_or("".to_owned()),
) )
.build(), .build(),
); );
s.append(&first_row); layout.append(&first_row);
s.append( layout.append(
&gtk::Label::builder() &gtk::Label::builder()
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.label( .label(
@ -103,6 +106,115 @@ impl TimeDistanceView {
.build(), .build(),
); );
layout
}
type OnUpdate = Rc<RefCell<Box<dyn Fn(TimeDistance)>>>;
pub struct TimeDistanceEditPrivate {
#[allow(unused)]
workout: RefCell<ft_core::TimeDistance>,
on_update: OnUpdate,
}
impl Default for TimeDistanceEditPrivate {
fn default() -> Self {
Self {
workout: RefCell::new(TimeDistance {
datetime: chrono::Utc::now().into(),
activity: TimeDistanceActivity::BikeRide,
duration: None,
distance: None,
comments: None,
}),
on_update: Rc::new(RefCell::new(Box::new(|_| {}))),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for TimeDistanceEditPrivate {
const NAME: &'static str = "TimeDistanceEdit";
type Type = TimeDistanceEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for TimeDistanceEditPrivate {}
impl WidgetImpl for TimeDistanceEditPrivate {}
impl BoxImpl for TimeDistanceEditPrivate {}
glib::wrapper! {
pub struct TimeDistanceEdit(ObjectSubclass<TimeDistanceEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Default for TimeDistanceEdit {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.set_css_classes(&["time-distance-edit"]);
s s
} }
} }
impl TimeDistanceEdit {
pub fn new<OnUpdate>(workout: TimeDistance, on_update: OnUpdate) -> Self
where
OnUpdate: Fn(TimeDistance) + 'static,
{
let s = Self::default();
*s.imp().workout.borrow_mut() = workout.clone();
*s.imp().on_update.borrow_mut() = Box::new(on_update);
let details_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
details_row.append(
&time_field(
Some(TimeFormatter::from(workout.datetime.naive_local().time())),
{
let s = s.clone();
move |t| s.update_time(t)
},
)
.widget(),
);
details_row.append(
&distance_field(workout.distance.map(DistanceFormatter::from), {
let s = s.clone();
move |d| s.update_distance(d)
})
.widget(),
);
details_row.append(
&duration_field(workout.duration.map(DurationFormatter::from), {
let s = s.clone();
move |d| s.update_duration(d)
})
.widget(),
);
s.append(&details_row);
s.append(&gtk::Entry::new());
s
}
fn update_time(&self, _time: Option<TimeFormatter>) {
unimplemented!()
}
fn update_distance(&self, distance: Option<DistanceFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.distance = distance.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
fn update_duration(&self, duration: Option<DurationFormatter>) {
let mut workout = self.imp().workout.borrow_mut();
workout.duration = duration.map(|d| *d);
(self.imp().on_update.borrow())(workout.clone());
}
}

View File

@ -14,10 +14,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <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::{ use crate::types::{FormatOption, WeightFormatter};
components::TextEntry,
types::{FormatOption, WeightFormatter},
};
use gtk::prelude::*; use gtk::prelude::*;
pub struct WeightLabel { pub struct WeightLabel {

View File

@ -62,6 +62,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

@ -53,6 +53,7 @@ impl Iterator for DayIterator {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatOption { pub enum FormatOption {
Abbreviated, Abbreviated,
#[allow(unused)]
Full, Full,
} }
@ -60,6 +61,7 @@ pub enum FormatOption {
pub struct TimeFormatter(chrono::NaiveTime); pub struct TimeFormatter(chrono::NaiveTime);
impl TimeFormatter { impl TimeFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String { pub fn format(&self, option: FormatOption) -> String {
match option { match option {
FormatOption::Abbreviated => self.0.format("%H:%M"), FormatOption::Abbreviated => self.0.format("%H:%M"),
@ -68,6 +70,7 @@ impl TimeFormatter {
.to_string() .to_string()
} }
#[allow(unused)]
pub fn parse(s: &str) -> Result<TimeFormatter, ParseError> { pub fn parse(s: &str) -> Result<TimeFormatter, ParseError> {
let parts = s let parts = s
.split(':') .split(':')
@ -104,6 +107,7 @@ impl From<chrono::NaiveTime> for TimeFormatter {
pub struct WeightFormatter(si::Kilogram<f64>); pub struct WeightFormatter(si::Kilogram<f64>);
impl WeightFormatter { impl WeightFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String { pub fn format(&self, option: FormatOption) -> String {
match option { match option {
FormatOption::Abbreviated => format!("{} kg", self.0.value_unsafe), FormatOption::Abbreviated => format!("{} kg", self.0.value_unsafe),
@ -111,6 +115,7 @@ impl WeightFormatter {
} }
} }
#[allow(unused)]
pub fn parse(s: &str) -> Result<WeightFormatter, ParseError> { pub fn parse(s: &str) -> Result<WeightFormatter, ParseError> {
s.parse::<f64>() s.parse::<f64>()
.map(|w| WeightFormatter(w * si::KG)) .map(|w| WeightFormatter(w * si::KG))
@ -149,6 +154,7 @@ impl From<si::Kilogram<f64>> for WeightFormatter {
pub struct DistanceFormatter(si::Meter<f64>); pub struct DistanceFormatter(si::Meter<f64>);
impl DistanceFormatter { impl DistanceFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String { pub fn format(&self, option: FormatOption) -> String {
match option { match option {
FormatOption::Abbreviated => format!("{} km", self.0.value_unsafe / 1000.), FormatOption::Abbreviated => format!("{} km", self.0.value_unsafe / 1000.),
@ -156,6 +162,7 @@ impl DistanceFormatter {
} }
} }
#[allow(unused)]
pub fn parse(s: &str) -> Result<DistanceFormatter, ParseError> { pub fn parse(s: &str) -> Result<DistanceFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?; let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DistanceFormatter(value * 1000. * si::M)) Ok(DistanceFormatter(value * 1000. * si::M))
@ -193,6 +200,7 @@ impl From<si::Meter<f64>> for DistanceFormatter {
pub struct DurationFormatter(si::Second<f64>); pub struct DurationFormatter(si::Second<f64>);
impl DurationFormatter { impl DurationFormatter {
#[allow(unused)]
pub fn format(&self, option: FormatOption) -> String { pub fn format(&self, option: FormatOption) -> String {
let (hours, minutes) = self.hours_and_minutes(); let (hours, minutes) = self.hours_and_minutes();
let (h, m) = match option { let (h, m) = match option {
@ -206,11 +214,13 @@ impl DurationFormatter {
} }
} }
#[allow(unused)]
pub fn parse(s: &str) -> Result<DurationFormatter, ParseError> { pub fn parse(s: &str) -> Result<DurationFormatter, ParseError> {
let value = s.parse::<f64>().map_err(|_| ParseError)?; let value = s.parse::<f64>().map_err(|_| ParseError)?;
Ok(DurationFormatter(value * 60. * si::S)) Ok(DurationFormatter(value * 60. * si::S))
} }
#[allow(unused)]
fn hours_and_minutes(&self) -> (i64, i64) { fn hours_and_minutes(&self) -> (i64, i64) {
let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64; let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64;
let hours: i64 = minutes / 60; let hours: i64 = minutes / 60;
@ -246,14 +256,6 @@ impl From<si::Second<f64>> for DurationFormatter {
} }
} }
/*
fn take_digits(s: String) -> String {
s.chars()
.take_while(|t| t.is_ascii_digit())
.collect::<String>()
}
*/
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View File

@ -14,44 +14,64 @@ 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, types::WeightFormatter}; use crate::app::{ReadError, RecordProvider};
use dimensioned::si;
use emseries::{Record, RecordId, Recordable}; use emseries::{Record, RecordId, Recordable};
use ft_core::TraxRecord; use ft_core::{TimeDistance, TimeDistanceActivity, TraxRecord};
use std::{ use std::{
collections::HashMap, collections::HashMap,
ops::Deref, ops::Deref,
sync::{Arc, RwLock}, sync::{Arc, RwLock},
}; };
// These are actually a used imports. Clippy isn't detecting their use, probably because of complexity around the async trait macros.
#[allow(unused_imports)]
use crate::app::WriteError;
#[allow(unused_imports)]
use chrono::NaiveDate;
#[derive(Clone, Debug)] #[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)]
Deleted(Record<T>), Deleted(Record<T>),
} }
impl<T: Clone + emseries::Recordable> RecordState<T> { impl<T: Clone + emseries::Recordable> RecordState<T> {
#[allow(unused)] fn exists(&self) -> bool {
fn id(&self) -> Option<&RecordId> {
match self { match self {
RecordState::Original(ref r) => Some(&r.id), RecordState::Original(_) => true,
RecordState::New(ref r) => None, RecordState::New(_) => true,
RecordState::Updated(ref r) => Some(&r.id), RecordState::Updated(_) => true,
RecordState::Deleted(ref r) => Some(&r.id), RecordState::Deleted(_) => false,
} }
} }
fn with_value(self, value: T) -> RecordState<T> { #[allow(unused)]
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 {
@ -68,19 +88,27 @@ 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)] impl<T: Clone + emseries::Recordable> std::ops::DerefMut for RecordState<T> {
struct DayDetailViewModelInner {} fn deref_mut(&mut self) -> &mut Self::Target {
match self {
RecordState::Original(ref mut r) => &mut r.data,
RecordState::New(ref mut r) => &mut r.data,
RecordState::Updated(ref mut r) => &mut r.data,
RecordState::Deleted(ref mut r) => &mut r.data,
}
}
}
#[derive(Clone, Default)] #[derive(Clone)]
pub struct DayDetailViewModel { pub struct DayDetailViewModel {
app: Option<App>, provider: Arc<dyn RecordProvider>,
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>>>>,
@ -88,58 +116,38 @@ pub struct DayDetailViewModel {
} }
impl DayDetailViewModel { impl DayDetailViewModel {
pub fn new(date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>, app: App) -> Self { pub async fn new(
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) = date: chrono::NaiveDate,
records.into_iter().partition(|r| r.data.is_weight()); provider: impl RecordProvider + 'static,
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) = ) -> Result<Self, ReadError> {
records.into_iter().partition(|r| r.data.is_steps()); let s = Self {
Self { provider: Arc::new(provider),
app: Some(app),
date, date,
weight: Arc::new(RwLock::new( weight: Arc::new(RwLock::new(None)),
weight_records steps: Arc::new(RwLock::new(None)),
.first() records: Arc::new(RwLock::new(HashMap::new())),
.and_then(|r| match r.data { };
TraxRecord::Weight(ref w) => Some((r.id.clone(), w.clone())), s.populate_records().await;
_ => None, Ok(s)
})
.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<WeightFormatter> { pub fn weight(&self) -> Option<si::Kilogram<f64>> {
(*self.weight.read().unwrap()) (*self.weight.read().unwrap()).as_ref().map(|w| w.weight)
.as_ref()
.map(|w| WeightFormatter::from(w.weight))
} }
pub fn set_weight(&self, new_weight: WeightFormatter) { pub fn set_weight(&self, new_weight: si::Kilogram<f64>) {
let mut record = self.weight.write().unwrap(); let mut record = self.weight.write().unwrap();
let new_record = match *record { let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight { Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
date: self.date, date: self.date,
weight: *new_weight, weight: new_weight,
}), }),
None => RecordState::New(ft_core::Weight { None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Weight {
date: self.date, date: self.date,
weight: *new_weight, weight: new_weight,
},
}), }),
}; };
*record = Some(new_record); *record = Some(new_record);
@ -156,27 +164,128 @@ impl DayDetailViewModel {
date: self.date, date: self.date,
count: new_count, count: new_count,
}), }),
None => RecordState::New(ft_core::Steps { None => RecordState::New(Record {
id: RecordId::default(),
data: ft_core::Steps {
date: self.date, date: self.date,
count: new_count, count: new_count,
},
}), }),
}; };
*record = Some(new_record); *record = Some(new_record);
} }
pub fn new_time_distance(&self, activity: TimeDistanceActivity) -> Record<TimeDistance> {
let id = RecordId::default();
let workout = TimeDistance {
datetime: chrono::Local::now().into(),
activity,
distance: None,
duration: None,
comments: None,
};
let tr = TraxRecord::from(workout.clone());
self.records
.write()
.unwrap()
.insert(id, RecordState::New(Record { id, data: tr }));
Record { id, data: workout }
}
pub fn time_distance_records(&self) -> Vec<Record<TimeDistance>> {
self.records
.read()
.unwrap()
.iter()
.filter(|(_, record)| record.exists())
.filter_map(|(id, record_state)| match **record_state {
TraxRecord::TimeDistance(ref workout) => Some(Record {
id: *id,
data: workout.clone(),
}),
_ => None,
})
.collect()
}
pub fn time_distance_summary(
&self,
activity: TimeDistanceActivity,
) -> (si::Meter<f64>, si::Second<f64>) {
self.time_distance_records()
.into_iter()
.filter(|rec| rec.data.activity == activity)
.fold(
(0. * si::M, 0. * si::S),
|(distance, duration), workout| match (workout.data.distance, workout.data.duration)
{
(Some(distance_), Some(duration_)) => {
(distance + distance_, duration + duration_)
}
(Some(distance_), None) => (distance + distance_, duration),
(None, Some(duration_)) => (distance, duration + duration_),
(None, None) => (distance, duration),
},
)
}
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>>>()
}
#[allow(unused)]
fn get_record(&self, id: &RecordId) -> Option<Record<TraxRecord>> {
let record_set = self.records.read().unwrap();
record_set.get(id).map(|record| Record {
id: *id,
data: (**record).clone(),
})
}
pub fn remove_record(&self, id: RecordId) {
let mut record_set = self.records.write().unwrap();
let updated_record = match record_set.remove(&id) {
Some(RecordState::Original(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::New(_)) => None,
Some(RecordState::Updated(r)) => Some(RecordState::Deleted(r)),
Some(RecordState::Deleted(r)) => Some(RecordState::Deleted(r)),
None => None,
};
if let Some(updated_record) = updated_record {
record_set.insert(id, updated_record);
}
}
pub fn save(&self) { pub fn save(&self) {
glib::spawn_future({
let s = self.clone(); let s = self.clone();
async move { glib::spawn_future(async move { s.async_save().await });
if let Some(app) = s.app { }
let weight_record = s.weight.read().unwrap().clone();
pub async fn async_save(&self) {
let weight_record = self.weight.read().unwrap().clone();
match weight_record { match weight_record {
Some(RecordState::New(weight)) => { Some(RecordState::New(data)) => {
let _ = app.put_record(TraxRecord::Weight(weight)).await; let _ = self
.provider
.put_record(TraxRecord::Weight(data.data))
.await;
} }
Some(RecordState::Original(_)) => {} Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(weight)) => { Some(RecordState::Updated(weight)) => {
let _ = app let _ = self
.provider
.update_record(Record { .update_record(Record {
id: weight.id, id: weight.id,
data: TraxRecord::Weight(weight.data), data: TraxRecord::Weight(weight.data),
@ -187,14 +296,15 @@ impl DayDetailViewModel {
None => {} None => {}
} }
let steps_record = s.steps.read().unwrap().clone(); let steps_record = self.steps.read().unwrap().clone();
match steps_record { match steps_record {
Some(RecordState::New(steps)) => { Some(RecordState::New(data)) => {
let _ = app.put_record(TraxRecord::Steps(steps)).await; let _ = self.provider.put_record(TraxRecord::Steps(data.data)).await;
} }
Some(RecordState::Original(_)) => {} Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(steps)) => { Some(RecordState::Updated(steps)) => {
let _ = app let _ = self
.provider
.update_record(Record { .update_record(Record {
id: steps.id, id: steps.id,
data: TraxRecord::Steps(steps.data), data: TraxRecord::Steps(steps.data),
@ -205,7 +315,7 @@ impl DayDetailViewModel {
None => {} None => {}
} }
let records = s let records = self
.records .records
.write() .write()
.unwrap() .unwrap()
@ -214,23 +324,322 @@ impl DayDetailViewModel {
.collect::<Vec<RecordState<TraxRecord>>>(); .collect::<Vec<RecordState<TraxRecord>>>();
for record in records { for record in records {
println!("saving record: {:?}", record);
match record { match record {
RecordState::New(data) => { RecordState::New(data) => {
let _ = app.put_record(data).await; let _ = self.provider.put_record(data.data).await;
} }
RecordState::Original(_) => {} RecordState::Original(_) => {}
RecordState::Updated(r) => { RecordState::Updated(r) => {
let _ = app.update_record(r.clone()).await; let _ = self.provider.update_record(r.clone()).await;
} }
RecordState::Deleted(_) => unimplemented!(), RecordState::Deleted(r) => {
let _ = self.provider.delete_record(r.id).await;
} }
} }
} }
} self.populate_records().await;
});
} }
pub fn revert(&self) { pub async fn revert(&self) {
unimplemented!(); self.populate_records().await;
}
async fn populate_records(&self) {
let records = self.provider.records(self.date, self.date).await.unwrap();
let (weight_records, records): (Vec<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>>>();
}
}
#[cfg(test)]
mod test {
use super::*;
use async_trait::async_trait;
use chrono::{DateTime, FixedOffset};
use dimensioned::si;
use emseries::Record;
#[derive(Clone, Debug)]
struct MockProvider {
records: Arc<RwLock<HashMap<RecordId, Record<TraxRecord>>>>,
put_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
updated_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
deleted_records: Arc<RwLock<Vec<RecordId>>>,
}
impl MockProvider {
fn new(records: Vec<Record<TraxRecord>>) -> Self {
let record_map = records
.into_iter()
.map(|r| (r.id, r))
.collect::<HashMap<RecordId, Record<TraxRecord>>>();
Self {
records: Arc::new(RwLock::new(record_map)),
put_records: Arc::new(RwLock::new(vec![])),
updated_records: Arc::new(RwLock::new(vec![])),
deleted_records: Arc::new(RwLock::new(vec![])),
}
}
}
#[async_trait]
impl RecordProvider for MockProvider {
async fn records(
&self,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
let start = emseries::Timestamp::Date(start);
let end = emseries::Timestamp::Date(end);
Ok(self
.records
.read()
.unwrap()
.iter()
.map(|(_, r)| r)
.filter(|r| r.timestamp() >= start && r.timestamp() <= end)
.cloned()
.collect::<Vec<Record<TraxRecord>>>())
}
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
let id = RecordId::default();
let record = Record {
id: id,
data: record,
};
self.put_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(id, record);
Ok(id)
}
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
println!("updated record: {:?}", record);
self.updated_records.write().unwrap().push(record.clone());
self.records.write().unwrap().insert(record.id, record);
Ok(())
}
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> {
self.deleted_records.write().unwrap().push(id);
let _ = self.records.write().unwrap().remove(&id);
Ok(())
}
}
async fn create_empty_view_model() -> (DayDetailViewModel, MockProvider) {
let provider = MockProvider::new(vec![]);
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
async fn create_view_model() -> (DayDetailViewModel, MockProvider) {
let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap();
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
let oct_13_am: DateTime<FixedOffset> = oct_13
.clone()
.and_hms_opt(3, 28, 0)
.unwrap()
.and_utc()
.with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap());
let provider = MockProvider::new(vec![
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_12,
weight: 93. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Weight(ft_core::Weight {
date: oct_13,
weight: 95. * si::KG,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::Steps(ft_core::Steps {
date: oct_13,
count: 2500,
}),
},
Record {
id: RecordId::default(),
data: TraxRecord::TimeDistance(ft_core::TimeDistance {
datetime: oct_13_am.clone(),
activity: TimeDistanceActivity::BikeRide,
distance: Some(15000. * si::M),
duration: Some(3600. * si::S),
comments: Some("somecomments present".to_owned()),
}),
},
]);
let model = DayDetailViewModel::new(oct_13, provider.clone())
.await
.unwrap();
(model, provider)
}
#[tokio::test]
async fn it_honors_only_the_first_weight_and_step_record() {
let (view_model, _provider) = create_view_model().await;
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(2500));
}
#[tokio::test]
async fn it_can_create_a_weight_and_stepcount() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(view_model.weight(), None);
assert_eq!(view_model.steps(), None);
view_model.set_weight(95. * si::KG);
view_model.set_steps(250);
assert_eq!(view_model.weight(), Some(95. * si::KG));
assert_eq!(view_model.steps(), Some(250));
view_model.set_weight(93. * si::KG);
view_model.set_steps(255);
assert_eq!(view_model.weight(), Some(93. * si::KG));
assert_eq!(view_model.steps(), Some(255));
view_model.async_save().await;
println!("provider: {:?}", provider);
assert_eq!(provider.put_records.read().unwrap().len(), 2);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_construct_new_records() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::BikeRide),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::BikeRide);
record.data.duration = Some(60. * si::S);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_a_new_record_before_saving() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::BikeRide),
(0. * si::M, 0. * si::S)
);
let mut record = view_model.new_time_distance(TimeDistanceActivity::BikeRide);
record.data.duration = Some(60. * si::S);
let record = record.map(TraxRecord::TimeDistance);
view_model.update_record(record.clone());
assert_eq!(view_model.get_record(&record.id), Some(record));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::BikeRide),
(0. * si::M, 60. * si::S)
);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::Running),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 1);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_update_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let mut workout = view_model.time_distance_records().first().cloned().unwrap();
workout.data.duration = Some(1800. * si::S);
view_model.update_record(workout.map(TraxRecord::TimeDistance));
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::BikeRide),
(15000. * si::M, 1800. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 1);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_remove_a_new_record() {
let (view_model, provider) = create_empty_view_model().await;
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::BikeRide),
(0. * si::M, 0. * si::S)
);
let record = view_model.new_time_distance(TimeDistanceActivity::BikeRide);
view_model.remove_record(record.id);
view_model.save();
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
}
#[tokio::test]
async fn it_can_delete_an_existing_record() {
let (view_model, provider) = create_view_model().await;
let workout = view_model.time_distance_records().first().cloned().unwrap();
view_model.remove_record(workout.id);
assert_eq!(
view_model.time_distance_summary(TimeDistanceActivity::BikeRide),
(0. * si::M, 0. * si::S)
);
view_model.async_save().await;
assert_eq!(provider.put_records.read().unwrap().len(), 0);
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
assert_eq!(provider.deleted_records.read().unwrap().len(), 1);
} }
} }

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,18 +57,26 @@ 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()
.expect("DayDetailView has not been initialized with a view_model")
.clone();
self.imp().container.swap(&DayDetail::new(view_model, {
let s = self.clone(); let s = self.clone();
move || s.edit() 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()
.expect("DayDetailView has not been initialized with a view_model")
.clone();
self.imp().container.swap(&DayEdit::new(view_model, {
let s = self.clone(); let s = self.clone();
move || s.view() move || s.view()
})); }));

View File

@ -17,12 +17,10 @@ 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 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 +54,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 +73,12 @@ 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())
records.records(), .await
app.clone(), .unwrap();
)); summary.set_data(view_model);
});
} }
} }
}); });
@ -96,13 +96,9 @@ 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) + '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);
@ -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());
} }
}); });
@ -136,12 +127,9 @@ impl HistoricalView {
s s
} }
pub fn set_records(&self, records: Vec<Record<TraxRecord>>) { pub fn set_interval(&self, interval: DayInterval) {
println!("set_records: {:?}", records); let mut model = gio::ListStore::new::<Date>();
let grouped_records = model.extend(interval.days().map(Date::new));
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() self.imp()
.list_view .list_view
.set_model(Some(&gtk::NoSelection::new(Some(model)))); .set_model(Some(&gtk::NoSelection::new(Some(model))));
@ -153,152 +141,30 @@ impl HistoricalView {
} }
#[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

@ -30,6 +30,7 @@ pub use welcome_view::WelcomeView;
pub enum View { pub enum View {
Placeholder(PlaceholderView), Placeholder(PlaceholderView),
#[allow(unused)]
Welcome(WelcomeView), Welcome(WelcomeView),
Historical(HistoricalView), Historical(HistoricalView),
} }

View File

@ -1,4 +1,4 @@
mod legacy; mod legacy;
mod types; mod types;
pub use types::{RecordType, Steps, TimeDistance, TraxRecord, Weight}; pub use types::{Steps, TimeDistance, TimeDistanceActivity, TraxRecord, Weight};

View File

@ -33,6 +33,15 @@ impl Recordable for Steps {
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum TimeDistanceActivity {
BikeRide,
Running,
Rowing,
Swimming,
Walking,
}
/// 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
@ -48,6 +57,8 @@ pub struct TimeDistance {
/// in the database, but we can still get a Naive Date from the DateTime, which will still read /// in the database, but we can still get a Naive Date from the DateTime, which will still read
/// as the original day. /// as the original day.
pub datetime: DateTime<FixedOffset>, pub datetime: DateTime<FixedOffset>,
/// The activity
pub activity: TimeDistanceActivity,
/// The distance travelled. This is optional because such a workout makes sense even without /// The distance travelled. This is optional because such a workout makes sense even without
/// the distance. /// the distance.
pub distance: Option<si::Meter<f64>>, pub distance: Option<si::Meter<f64>>,
@ -57,6 +68,16 @@ pub struct TimeDistance {
pub comments: Option<String>, pub comments: Option<String>,
} }
impl Recordable for TimeDistance {
fn timestamp(&self) -> Timestamp {
Timestamp::DateTime(self.datetime)
}
fn tags(&self) -> Vec<String> {
vec![]
}
}
/// 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)]
@ -75,42 +96,15 @@ impl Recordable for Weight {
} }
} }
#[derive(Clone, Debug, PartialEq)]
pub enum RecordType {
BikeRide,
Row,
Run,
Steps,
Swim,
Walk,
Weight,
}
/// The unified data structure for all records that are part of the app. /// The unified data structure for all records that are part of the app.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum TraxRecord { pub enum TraxRecord {
BikeRide(TimeDistance), TimeDistance(TimeDistance),
Row(TimeDistance),
Run(TimeDistance),
Steps(Steps), Steps(Steps),
Swim(TimeDistance),
Walk(TimeDistance),
Weight(Weight), Weight(Weight),
} }
impl TraxRecord { impl TraxRecord {
pub fn workout_type(&self) -> RecordType {
match self {
TraxRecord::BikeRide(_) => RecordType::BikeRide,
TraxRecord::Row(_) => RecordType::Row,
TraxRecord::Run(_) => RecordType::Run,
TraxRecord::Steps(_) => RecordType::Steps,
TraxRecord::Swim(_) => RecordType::Swim,
TraxRecord::Walk(_) => RecordType::Walk,
TraxRecord::Weight(_) => RecordType::Weight,
}
}
pub fn is_weight(&self) -> bool { pub fn is_weight(&self) -> bool {
matches!(self, TraxRecord::Weight(_)) matches!(self, TraxRecord::Weight(_))
} }
@ -118,17 +112,35 @@ 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_time_distance(&self) -> bool {
matches!(
self,
TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::BikeRide,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Running,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Rowing,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Swimming,
..
}) | TraxRecord::TimeDistance(TimeDistance {
activity: TimeDistanceActivity::Walking,
..
})
)
}
} }
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), TraxRecord::TimeDistance(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Row(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Run(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Steps(rec) => rec.timestamp(), TraxRecord::Steps(rec) => rec.timestamp(),
TraxRecord::Swim(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Walk(rec) => Timestamp::DateTime(rec.datetime),
TraxRecord::Weight(rec) => rec.timestamp(), TraxRecord::Weight(rec) => rec.timestamp(),
} }
} }
@ -138,6 +150,12 @@ impl Recordable for TraxRecord {
} }
} }
impl From<TimeDistance> for TraxRecord {
fn from(td: TimeDistance) -> Self {
Self::TimeDistance(td)
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

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);
}