Compare commits

..

16 Commits

Author SHA1 Message Date
Savanni D'Gerinel f8d66bbb69 Update build tools for dashboard and fitnesstrax 2024-01-25 22:55:45 -05:00
Savanni D'Gerinel dce11dde2b Set up flake-based builds 2024-01-25 22:48:41 -05:00
Savanni D'Gerinel 3f9a7072eb Fitnesstrax, version 0.3.0 2024-01-21 10:14:50 -05:00
Savanni D'Gerinel 7ec48ded5d Make the day summary use the view model 2024-01-20 17:04:20 -05:00
Savanni D'Gerinel 9461c387fe Simplify the weight editor 2024-01-20 16:05:33 -05:00
Savanni D'Gerinel d4c48c4443 Add a step count editor field 2024-01-20 15:59:03 -05:00
Savanni D'Gerinel 9bedb7a76c Tons of linting and get tests running again 2024-01-20 15:04:46 -05:00
Savanni D'Gerinel 1fe318068b Set up a view model for the day detail view 2024-01-20 11:16:31 -05:00
Savanni D'Gerinel 18e7e4fe2f Start setting up the day detail view model
I've created the view model and added a getter function for the weight.
I'm passing the view model now to the DayDetailView, DayDetail, and
DayEdit.

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

None of the components yet save any updates to the view model, so
updated_records is always going to be empty until I figure that out.
2024-01-18 09:00:08 -05:00
Savanni D'Gerinel 1c2c4982a1 Update the record in the detail view on save 2024-01-18 07:43:18 -05:00
Savanni D'Gerinel c075b7ed6e Just barely get the data saveable again
Starting to see some pretty serious limitations already.
2024-01-17 22:59:20 -05:00
Savanni D'Gerinel 56d0a53666 Fix how DayEdit deals with the weight field 2024-01-17 22:35:13 -05:00
Savanni D'Gerinel b00acc64a3 Set up the ActionGroup component 2024-01-17 22:13:55 -05:00
Savanni D'Gerinel 104760c754 Be able to switch into edit mode 2024-01-15 23:27:55 -05:00
Savanni D'Gerinel 1e6555ef61 Create a day detail view
DayDetail, the component, I used to use as a view. Now I'm swapping
things out so that DayDetailView handles the view itself. The DayDetail
component will still show the details of the day, but I'll create a
DayEditComponent which is dedicated to showing the edit interface for
everything in a day.

The swapping will now happen in DayDetailView, not in DayDetail or an
even deeper component.
2024-01-15 15:53:01 -05:00
Savanni D'Gerinel 2e2ff6b47e Create a Singleton component and use it to simplify the weight view 2024-01-15 13:20:23 -05:00
34 changed files with 1002 additions and 412 deletions

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

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

5
Cargo.lock generated
View File

@ -642,7 +642,7 @@ dependencies = [
"geo-types",
"gio",
"glib",
"glib-build-tools 0.16.3",
"glib-build-tools 0.18.0",
"gtk4",
"ifc",
"lazy_static",
@ -925,7 +925,7 @@ checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
[[package]]
name = "fitnesstrax"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"async-channel",
"chrono",
@ -938,6 +938,7 @@ dependencies = [
"glib-build-tools 0.18.0",
"gtk4",
"libadwaita",
"thiserror",
"tokio",
]

View File

@ -2138,7 +2138,7 @@ rec {
buildDependencies = [
{
name = "glib-build-tools";
packageId = "glib-build-tools 0.16.3";
packageId = "glib-build-tools 0.18.0";
}
];
@ -2953,7 +2953,7 @@ rec {
};
"fitnesstrax" = rec {
crateName = "fitnesstrax";
version = "0.2.0";
version = "0.3.0";
edition = "2021";
crateBin = [
{
@ -3013,6 +3013,10 @@ rec {
rename = "adw";
features = [ "v1_4" ];
}
{
name = "thiserror";
packageId = "thiserror";
}
{
name = "tokio";
packageId = "tokio";

View File

@ -28,5 +28,5 @@ tokio = { version = "1", features = ["full"] }
unic-langid = { version = "0.9" }
[build-dependencies]
glib-build-tools = "0.16"
glib-build-tools = "0.18"

View File

@ -1,7 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
"resources",
"resources/gresources.xml",
&["resources"],
"gresources.xml",
"com.luminescent-dreams.dashboard.gresource",
);
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
[package]
name = "fitnesstrax"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -16,6 +16,7 @@ ft-core = { path = "../core" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
thiserror = { version = "1.0" }
tokio = { version = "1.34", features = [ "full" ] }
[build-dependencies]

View File

@ -1,12 +1,7 @@
fn main() {
let resources_path = std::env::var_os("PWD").unwrap().clone();
let resources_path = resources_path.to_string_lossy().to_owned();
let resources_path = resources_path + "/resources/gresources.xml";
eprintln!("resources: {}", resources_path);
glib_build_tools::compile_resources(
&["resources"],
resources_path.as_ref(),
"gresources.xml",
"com.luminescent-dreams.fitnesstrax.gresource",
);
}

View File

@ -11,8 +11,7 @@
padding: 8px;
}
.welcome__footer {
}
.welcome__footer {}
.historical {
margin: 32px;
@ -37,3 +36,7 @@
margin: 8px;
}
.step-view {
padding: 8px;
margin: 8px;
}

View File

@ -14,7 +14,6 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::types::DayInterval;
use chrono::NaiveDate;
use emseries::{time_range, Record, RecordId, Series, Timestamp};
use ft_core::TraxRecord;
@ -22,11 +21,16 @@ use std::{
path::PathBuf,
sync::{Arc, RwLock},
};
use thiserror::Error;
use tokio::runtime::Runtime;
#[derive(Debug, Error)]
pub enum AppError {
#[error("no database loaded")]
NoDatabase,
#[error("failed to open the database")]
FailedToOpenDatabase,
#[error("unhandled error")]
Unhandled,
}
@ -47,12 +51,10 @@ impl App {
.unwrap(),
);
let s = Self {
Self {
runtime,
database: Arc::new(RwLock::new(database)),
};
s
}
}
pub async fn records(
@ -71,7 +73,7 @@ impl App {
Timestamp::Date(end),
true,
))
.map(|record| record.clone())
.cloned()
.collect::<Vec<Record<TraxRecord>>>();
Ok(records)
} else {

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

View File

@ -0,0 +1,137 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! ActionGroup and related structures
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
#[derive(Default)]
pub struct ActionGroupPrivate;
#[glib::object_subclass]
impl ObjectSubclass for ActionGroupPrivate {
const NAME: &'static str = "ActionGroup";
type Type = ActionGroup;
type ParentType = gtk::Box;
}
impl ObjectImpl for ActionGroupPrivate {}
impl WidgetImpl for ActionGroupPrivate {}
impl BoxImpl for ActionGroupPrivate {}
glib::wrapper! {
pub struct ActionGroup(ObjectSubclass<ActionGroupPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl ActionGroup {
fn new(builder: ActionGroupBuilder) -> Self {
let s: Self = Object::builder().build();
s.set_orientation(builder.orientation);
let primary_button = builder.primary_action.button();
let secondary_button = builder.secondary_action.map(|action| action.button());
let tertiary_button = builder.tertiary_action.map(|action| action.button());
if let Some(button) = tertiary_button {
s.append(&button);
}
s.set_halign(gtk::Align::End);
if let Some(button) = secondary_button {
s.append(&button);
}
s.append(&primary_button);
s
}
pub fn builder() -> ActionGroupBuilder {
ActionGroupBuilder {
orientation: gtk::Orientation::Horizontal,
primary_action: Action {
label: "Ok".to_owned(),
action: Box::new(|| {}),
},
secondary_action: None,
tertiary_action: None,
}
}
}
struct Action {
label: String,
action: Box<dyn Fn()>,
}
impl Action {
fn button(self) -> gtk::Button {
let button = gtk::Button::builder().label(self.label).build();
button.connect_clicked(move |_| (self.action)());
button
}
}
pub struct ActionGroupBuilder {
orientation: gtk::Orientation,
primary_action: Action,
secondary_action: Option<Action>,
tertiary_action: Option<Action>,
}
impl ActionGroupBuilder {
pub fn orientation(mut self, orientation: gtk::Orientation) -> Self {
self.orientation = orientation;
self
}
pub fn primary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.primary_action = Action {
label: label.to_owned(),
action: Box::new(action),
};
self
}
pub fn secondary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.secondary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn tertiary_action<A>(mut self, label: &str, action: A) -> Self
where
A: Fn() + 'static,
{
self.tertiary_action = Some(Action {
label: label.to_owned(),
action: Box::new(action),
});
self
}
pub fn build(self) -> ActionGroup {
ActionGroup::new(self)
}
}

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.
@ -16,16 +16,16 @@ You should have received a copy of the GNU General Public License along with Fit
// use chrono::NaiveDate;
// use ft_core::TraxRecord;
use crate::components::{TimeDistanceView, WeightView};
use emseries::Record;
use ft_core::{RecordType, TraxRecord, Weight};
use crate::{
components::{steps_editor, weight_editor, ActionGroup, Steps, Weight},
view_models::DayDetailViewModel,
};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
pub struct DaySummaryPrivate {
date: gtk::Label,
weight: RefCell<Option<gtk::Label>>,
}
#[glib::object_subclass]
@ -39,10 +39,7 @@ impl ObjectSubclass for DaySummaryPrivate {
.css_classes(["day-summary__date"])
.halign(gtk::Align::Start)
.build();
Self {
date,
weight: RefCell::new(None),
}
Self { date }
}
}
@ -56,8 +53,8 @@ glib::wrapper! {
pub struct DaySummary(ObjectSubclass<DaySummaryPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DaySummary {
pub fn new() -> Self {
impl Default for DaySummary {
fn default() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["day-summary"]);
@ -66,62 +63,52 @@ impl DaySummary {
s
}
}
pub fn set_data(&self, date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>) {
impl DaySummary {
pub fn new() -> Self {
Self::default()
}
pub fn set_data(&self, view_model: DayDetailViewModel) {
self.imp()
.date
.set_text(&date.format("%Y-%m-%d").to_string());
.set_text(&view_model.date.format("%Y-%m-%d").to_string());
if let Some(ref weight_label) = *self.imp().weight.borrow() {
self.remove(weight_label);
}
let row = gtk::Box::builder().build();
if let Some(Record {
data: TraxRecord::Weight(weight_record),
..
}) = records.iter().filter(|f| f.data.is_weight()).next()
{
let label = gtk::Label::builder()
.halign(gtk::Align::Start)
.label(&format!("{}", weight_record.weight))
.css_classes(["day-summary__weight"])
.build();
if let Some(w) = view_model.weight() {
label.set_label(&w.to_string())
}
row.append(&label);
self.append(&label);
*self.imp().weight.borrow_mut() = Some(label);
}
/*
self.append(
&gtk::Label::builder()
let label = gtk::Label::builder()
.halign(gtk::Align::Start)
.label("15km of biking in 60 minutes")
.build(),
);
*/
.css_classes(["day-summary__weight"])
.build();
if let Some(s) = view_model.steps() {
label.set_label(&format!("{} steps", s.to_string()));
}
row.append(&label);
self.append(&row);
}
}
pub struct DayDetailPrivate {
date: gtk::Label,
weight: RefCell<Option<gtk::Label>>,
}
#[derive(Default)]
pub struct DayDetailPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for DayDetailPrivate {
const NAME: &'static str = "DayDetail";
type Type = DayDetail;
type ParentType = gtk::Box;
fn new() -> Self {
let date = gtk::Label::builder()
.css_classes(["daysummary-date"])
.halign(gtk::Align::Start)
.build();
Self {
date,
weight: RefCell::new(None),
}
}
}
impl ObjectImpl for DayDetailPrivate {}
@ -133,19 +120,21 @@ glib::wrapper! {
}
impl DayDetail {
pub fn new<PutRecordFn, UpdateRecordFn>(
date: chrono::NaiveDate,
records: Vec<Record<TraxRecord>>,
on_put_record: PutRecordFn,
on_update_record: UpdateRecordFn,
) -> Self
pub fn new<OnEdit>(view_model: DayDetailViewModel, on_edit: OnEdit) -> Self
where
PutRecordFn: Fn(TraxRecord) + 'static,
UpdateRecordFn: Fn(Record<TraxRecord>) + 'static,
OnEdit: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
s.append(
&ActionGroup::builder()
.primary_action("Edit", Box::new(on_edit))
.build(),
);
/*
let click_controller = gtk::GestureClick::new();
click_controller.connect_released({
let s = s.clone();
@ -158,52 +147,63 @@ impl DayDetail {
}
});
s.add_controller(click_controller);
*/
/*
let weight_record = records.iter().find_map(|record| match record {
Record {
id,
data: TraxRecord::Weight(record),
data: ft_core::TraxRecord::Weight(record),
} => Some((id.clone(), record.clone())),
_ => None,
});
*/
let weight_view = match weight_record {
Some((id, data)) => WeightView::new(date.clone(), Some(data.clone()), move |weight| {
on_update_record(Record {
id: id.clone(),
data: TraxRecord::Weight(Weight { date, weight }),
})
}),
None => WeightView::new(date.clone(), None, move |weight| {
on_put_record(TraxRecord::Weight(Weight { date, weight }));
}),
};
s.append(&weight_view);
let top_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
let weight_view = Weight::new(view_model.weight());
top_row.append(&weight_view.widget());
let steps_view = Steps::new(view_model.steps());
top_row.append(&steps_view.widget());
s.append(&top_row);
/*
records.into_iter().for_each(|record| {
let record_view = match record {
Record {
data: TraxRecord::BikeRide(record),
data: ft_core::TraxRecord::BikeRide(record),
..
} => Some(
TimeDistanceView::new(RecordType::BikeRide, record).upcast::<gtk::Widget>(),
TimeDistanceView::new(ft_core::RecordType::BikeRide, record)
.upcast::<gtk::Widget>(),
),
Record {
data: TraxRecord::Row(record),
data: ft_core::TraxRecord::Row(record),
..
} => Some(TimeDistanceView::new(RecordType::Row, record).upcast::<gtk::Widget>()),
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
Record {
data: TraxRecord::Run(record),
data: ft_core::TraxRecord::Run(record),
..
} => Some(TimeDistanceView::new(RecordType::Row, record).upcast::<gtk::Widget>()),
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
Record {
data: TraxRecord::Swim(record),
data: ft_core::TraxRecord::Swim(record),
..
} => Some(TimeDistanceView::new(RecordType::Row, record).upcast::<gtk::Widget>()),
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
Record {
data: TraxRecord::Walk(record),
data: ft_core::TraxRecord::Walk(record),
..
} => Some(TimeDistanceView::new(RecordType::Row, record).upcast::<gtk::Widget>()),
} => Some(
TimeDistanceView::new(ft_core::RecordType::Row, record).upcast::<gtk::Widget>(),
),
_ => None,
};
@ -214,7 +214,97 @@ impl DayDetail {
s.append(&record_view);
}
});
*/
s
}
}
pub struct DayEditPrivate {
on_finished: RefCell<Box<dyn Fn()>>,
}
impl Default for DayEditPrivate {
fn default() -> Self {
Self {
on_finished: RefCell::new(Box::new(|| {})),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for DayEditPrivate {
const NAME: &'static str = "DayEdit";
type Type = DayEdit;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayEditPrivate {}
impl WidgetImpl for DayEditPrivate {}
impl BoxImpl for DayEditPrivate {}
glib::wrapper! {
pub struct DayEdit(ObjectSubclass<DayEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DayEdit {
pub fn new<OnFinished>(view_model: DayDetailViewModel, on_finished: OnFinished) -> Self
where
OnFinished: Fn() + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_hexpand(true);
*s.imp().on_finished.borrow_mut() = Box::new(on_finished);
s.append(
&ActionGroup::builder()
.primary_action("Save", {
let s = s.clone();
let view_model = view_model.clone();
move || {
view_model.save();
s.finish();
}
})
.secondary_action("Cancel", {
let s = s.clone();
let view_model = view_model.clone();
move || {
view_model.revert();
s.finish();
}
})
.build(),
);
let top_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.build();
top_row.append(
&weight_editor(view_model.weight(), {
let view_model = view_model.clone();
move |w| {
view_model.set_weight(w);
}
})
.widget(),
);
top_row.append(
&steps_editor(view_model.steps(), {
let view_model = view_model.clone();
move |s| view_model.set_steps(s)
})
.widget(),
);
s.append(&top_row);
s
}
fn finish(&self) {
(self.imp().on_finished.borrow())()
}
}

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.
@ -14,11 +14,17 @@ 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/>.
*/
mod day;
pub use day::{DayDetail, DaySummary};
mod action_group;
pub use action_group::ActionGroup;
mod edit_view;
pub use edit_view::EditView;
mod day;
pub use day::{DayDetail, DayEdit, DaySummary};
mod singleton;
pub use singleton::{Singleton, SingletonImpl};
mod steps;
pub use steps::{steps_editor, Steps};
mod text_entry;
pub use text_entry::{ParseError, TextEntry};
@ -27,7 +33,7 @@ mod time_distance;
pub use time_distance::TimeDistanceView;
mod weight;
pub use weight::WeightView;
pub use weight::{weight_editor, Weight};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};

View File

@ -0,0 +1,71 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
//! A Widget container for a single components
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
pub struct SingletonPrivate {
widget: RefCell<gtk::Widget>,
}
impl Default for SingletonPrivate {
fn default() -> Self {
Self {
widget: RefCell::new(gtk::Label::new(None).upcast()),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for SingletonPrivate {
const NAME: &'static str = "Singleton";
type Type = Singleton;
type ParentType = gtk::Box;
}
impl ObjectImpl for SingletonPrivate {}
impl WidgetImpl for SingletonPrivate {}
impl BoxImpl for SingletonPrivate {}
glib::wrapper! {
/// The Singleton component contains exactly one child widget. The swap function makes it easy
/// to handle the job of swapping that child out for a different one.
pub struct Singleton(ObjectSubclass<SingletonPrivate>) @extends gtk::Box, gtk::Widget;
}
impl Default for Singleton {
fn default() -> Self {
let s: Self = Object::builder().build();
s.append(&*s.imp().widget.borrow());
s
}
}
impl Singleton {
pub fn swap(&self, new_widget: &impl IsA<gtk::Widget>) {
let new_widget = new_widget.clone().upcast();
self.remove(&*self.imp().widget.borrow());
self.append(&new_widget);
*self.imp().widget.borrow_mut() = new_widget;
}
}
pub trait SingletonImpl: WidgetImpl + BoxImpl {}
unsafe impl<T: SingletonImpl> IsSubclassable<T> for Singleton {}

View File

@ -0,0 +1,61 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::components::{ParseError, TextEntry};
use gtk::prelude::*;
#[derive(Default)]
pub struct Steps {
label: gtk::Label,
}
impl Steps {
pub fn new(steps: Option<u32>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "step-view"])
.can_focus(true)
.build();
match steps {
Some(s) => label.set_text(&format!("{}", s)),
None => label.set_text("No steps recorded"),
}
Self { label }
}
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}
pub fn steps_editor<OnUpdate>(value: Option<u32>, on_update: OnUpdate) -> TextEntry<u32>
where
OnUpdate: Fn(u32) + 'static,
{
TextEntry::new(
"0",
value,
|v| format!("{}", v),
move |v| match v.parse::<u32>() {
Ok(val) => {
on_update(val);
Ok(val)
}
Err(_) => Err(ParseError),
},
)
}

View File

@ -17,34 +17,48 @@ You should have received a copy of the GNU General Public License along with Fit
use gtk::prelude::*;
use std::{cell::RefCell, rc::Rc};
#[derive(Clone, Debug)]
pub struct ParseError;
type Renderer<T> = dyn Fn(&T) -> String;
type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
#[derive(Clone)]
pub struct TextEntry<T: Clone> {
pub struct TextEntry<T: Clone + std::fmt::Debug> {
value: Rc<RefCell<Option<T>>>,
widget: gtk::Entry,
renderer: Rc<Box<dyn Fn(&T) -> String>>,
validator: Rc<Box<dyn Fn(&str) -> Result<T, ParseError>>>,
#[allow(unused)]
renderer: Rc<Renderer<T>>,
parser: Rc<Parser<T>>,
}
impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(
f,
"{{ value: {:?}, widget: {:?} }}",
self.value, self.widget
)
}
}
// I do not understand why the data should be 'static.
impl<T: Clone + 'static> TextEntry<T> {
pub fn new<R, V>(placeholder: &str, value: Option<T>, renderer: R, validator: V) -> Self
impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
pub fn new<R, V>(placeholder: &str, value: Option<T>, renderer: R, parser: V) -> Self
where
R: Fn(&T) -> String + 'static,
V: Fn(&str) -> Result<T, ParseError> + 'static,
{
let widget = gtk::Entry::builder().placeholder_text(placeholder).build();
match value {
Some(ref v) => widget.set_text(&renderer(&v)),
None => {}
if let Some(ref v) = value {
widget.set_text(&renderer(v))
}
let s = Self {
value: Rc::new(RefCell::new(value)),
widget,
renderer: Rc::new(Box::new(renderer)),
validator: Rc::new(Box::new(validator)),
renderer: Rc::new(renderer),
parser: Rc::new(parser),
};
s.widget.buffer().connect_text_notify({
@ -61,7 +75,7 @@ impl<T: Clone + 'static> TextEntry<T> {
self.widget.remove_css_class("error");
return;
}
match (self.validator)(buffer.text().as_str()) {
match (self.parser)(buffer.text().as_str()) {
Ok(v) => {
*self.value.borrow_mut() = Some(v);
self.widget.remove_css_class("error");
@ -73,18 +87,20 @@ impl<T: Clone + 'static> TextEntry<T> {
}
}
#[allow(unused)]
pub fn value(&self) -> Option<T> {
let v = self.value.borrow().clone();
self.value.borrow().clone()
}
pub fn set_value(&self, value: Option<T>) {
match value {
Some(ref v) => self.widget.set_text(&(self.renderer)(&v)),
None => {}
if let Some(ref v) = value {
self.widget.set_text(&(self.renderer)(v))
}
*self.value.borrow_mut() = value;
}
#[allow(unused)]
pub fn grab_focus(&self) {
self.widget.grab_focus();
}

View File

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

View File

@ -14,173 +14,54 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::components::{EditView, ParseError, TextEntry};
use chrono::{Local, NaiveDate};
use crate::components::{ParseError, TextEntry};
use dimensioned::si;
use ft_core::Weight;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
use gtk::prelude::*;
pub struct WeightViewPrivate {
date: RefCell<NaiveDate>,
record: RefCell<Option<Weight>>,
widget: RefCell<EditView<gtk::Label, TextEntry<si::Kilogram<f64>>>>,
on_edit_finished: RefCell<Box<dyn Fn(si::Kilogram<f64>)>>,
pub struct Weight {
label: gtk::Label,
}
impl Default for WeightViewPrivate {
fn default() -> Self {
Self {
date: RefCell::new(Local::now().date_naive()),
record: RefCell::new(None),
widget: RefCell::new(EditView::Unconfigured),
on_edit_finished: RefCell::new(Box::new(|_| {})),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for WeightViewPrivate {
const NAME: &'static str = "WeightView";
type Type = WeightView;
type ParentType = gtk::Box;
}
impl ObjectImpl for WeightViewPrivate {}
impl WidgetImpl for WeightViewPrivate {}
impl BoxImpl for WeightViewPrivate {}
glib::wrapper! {
pub struct WeightView(ObjectSubclass<WeightViewPrivate>) @extends gtk::Box, gtk::Widget;
}
impl WeightView {
pub fn new<OnEditFinished>(
date: NaiveDate,
weight: Option<Weight>,
on_edit_finished: OnEditFinished,
) -> Self
where
OnEditFinished: Fn(si::Kilogram<f64>) + 'static,
{
let s: Self = Object::builder().build();
*s.imp().on_edit_finished.borrow_mut() = Box::new(on_edit_finished);
*s.imp().date.borrow_mut() = date;
*s.imp().record.borrow_mut() = weight;
s.view();
s
}
fn view(&self) {
let view = gtk::Label::builder()
impl Weight {
pub fn new(weight: Option<si::Kilogram<f64>>) -> Self {
let label = gtk::Label::builder()
.css_classes(["card", "weight-view"])
.halign(gtk::Align::Start)
.can_focus(true)
.build();
let view_click_controller = gtk::GestureClick::new();
view_click_controller.connect_released({
let s = self.clone();
move |_, _, _, _| {
s.edit();
match weight {
Some(w) => label.set_text(&format!("{:?}", w)),
None => label.set_text("No weight recorded"),
}
});
view.add_controller(view_click_controller);
match *self.imp().record.borrow() {
Some(ref record) => {
view.remove_css_class("dim_label");
view.set_label(&format!("{:?}", record.weight));
Self { label }
}
None => {
view.add_css_class("dim_label");
view.set_label("No weight recorded");
pub fn widget(&self) -> gtk::Widget {
self.label.clone().upcast()
}
}
self.swap(EditView::View(view));
}
fn edit(&self) {
let edit = TextEntry::<si::Kilogram<f64>>::new(
"weight",
None,
pub fn weight_editor<OnUpdate>(
weight: Option<si::Kilogram<f64>>,
on_update: OnUpdate,
) -> TextEntry<si::Kilogram<f64>>
where
OnUpdate: Fn(si::Kilogram<f64>) + 'static,
{
TextEntry::new(
"0 kg",
weight,
|val: &si::Kilogram<f64>| val.to_string(),
|v: &str| v.parse::<f64>().map(|w| w * si::KG).map_err(|_| ParseError),
);
match *self.imp().record.borrow() {
Some(ref record) => edit.set_value(Some(record.weight)),
None => edit.set_value(None),
move |v: &str| {
let new_weight = v.parse::<f64>().map(|w| w * si::KG).map_err(|_| ParseError);
match new_weight {
Ok(w) => {
on_update(w);
Ok(w)
}
self.swap(EditView::Edit(edit.clone()));
edit.grab_focus();
}
fn swap(&self, new_view: EditView<gtk::Label, TextEntry<si::Kilogram<f64>>>) {
let mut widget = self.imp().widget.borrow_mut();
match *widget {
EditView::Unconfigured => {}
EditView::View(ref view) => self.remove(view),
EditView::Edit(ref editor) => self.remove(&editor.widget()),
}
match new_view {
EditView::Unconfigured => {}
EditView::View(ref view) => self.append(view),
EditView::Edit(ref editor) => self.append(&editor.widget()),
}
*widget = new_view;
}
pub fn blur(&self) {
match *self.imp().widget.borrow() {
EditView::Unconfigured => {}
EditView::View(_) => {}
EditView::Edit(ref editor) => {
let weight = editor.value();
// This has really turned into rubbish
// on_edit_finished needs to accept a full record now.
// needs to be possible to delete a record if the value is None
// it's hard to be sure whether I need the full record object or if I need to update
// it. I probably don't. I think I need to borrow it and call on_edit_finished with an
// updated version of it.
// on_edit_finished still doesn't have a way to support a delete operation
let record = match (self.imp().record.borrow().clone(), weight) {
// update an existing record
(Some(record), Some(weight)) => Some(Weight {
date: record.date,
weight,
}),
// create a new record
(None, Some(weight)) => Some(Weight {
date: self.imp().date.borrow().clone(),
weight,
}),
// do nothing or delete an existing record
(_, None) => None,
};
match record {
Some(record) => {
self.imp().on_edit_finished.borrow()(record.weight);
*self.imp().record.borrow_mut() = Some(record);
}
None => {}
}
}
}
self.view();
Err(err) => Err(err),
}
},
)
}

View File

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

View File

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

View File

@ -0,0 +1,235 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::app::App;
use dimensioned::si;
use emseries::{Record, RecordId, Recordable};
use ft_core::TraxRecord;
use std::{
collections::HashMap,
ops::Deref,
sync::{Arc, RwLock},
};
#[derive(Clone, Debug)]
enum RecordState<T: Clone + Recordable> {
Original(Record<T>),
New(T),
Updated(Record<T>),
#[allow(unused)]
Deleted(Record<T>),
}
impl<T: Clone + emseries::Recordable> RecordState<T> {
#[allow(unused)]
fn id(&self) -> Option<&RecordId> {
match self {
RecordState::Original(ref r) => Some(&r.id),
RecordState::New(ref r) => None,
RecordState::Updated(ref r) => Some(&r.id),
RecordState::Deleted(ref r) => Some(&r.id),
}
}
fn with_value(self, value: T) -> RecordState<T> {
match self {
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..r }),
RecordState::New(_) => RecordState::New(value),
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..r }),
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..r }),
}
}
#[allow(unused)]
fn with_delete(self) -> Option<RecordState<T>> {
match self {
RecordState::Original(r) => Some(RecordState::Deleted(r)),
RecordState::New(r) => None,
RecordState::Updated(r) => Some(RecordState::Deleted(r)),
RecordState::Deleted(r) => Some(RecordState::Deleted(r)),
}
}
}
impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
match self {
RecordState::Original(ref r) => &r.data,
RecordState::New(ref r) => r,
RecordState::Updated(ref r) => &r.data,
RecordState::Deleted(ref r) => &r.data,
}
}
}
#[derive(Default)]
struct DayDetailViewModelInner {}
#[derive(Clone, Default)]
pub struct DayDetailViewModel {
app: Option<App>,
pub date: chrono::NaiveDate,
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>,
}
impl DayDetailViewModel {
pub fn new(date: chrono::NaiveDate, records: Vec<Record<TraxRecord>>, app: App) -> Self {
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_weight());
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
records.into_iter().partition(|r| r.data.is_steps());
Self {
app: Some(app),
date,
weight: Arc::new(RwLock::new(
weight_records
.first()
.and_then(|r| match r.data {
TraxRecord::Weight(ref w) => Some((r.id.clone(), w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w })),
)),
steps: Arc::new(RwLock::new(
step_records
.first()
.and_then(|r| match r.data {
TraxRecord::Steps(ref w) => Some((r.id.clone(), w.clone())),
_ => None,
})
.map(|(id, w)| RecordState::Original(Record { id, data: w })),
)),
records: Arc::new(RwLock::new(
records
.into_iter()
.map(|r| (r.id.clone(), RecordState::Original(r)))
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>(),
)),
}
}
pub fn weight(&self) -> Option<si::Kilogram<f64>> {
(*self.weight.read().unwrap()).as_ref().map(|w| w.weight)
}
pub fn set_weight(&self, new_weight: si::Kilogram<f64>) {
let mut record = self.weight.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
date: self.date,
weight: new_weight,
}),
None => RecordState::New(ft_core::Weight {
date: self.date,
weight: new_weight,
}),
};
*record = Some(new_record);
}
pub fn steps(&self) -> Option<u32> {
(*self.steps.read().unwrap()).as_ref().map(|w| w.count)
}
pub fn set_steps(&self, new_count: u32) {
let mut record = self.steps.write().unwrap();
let new_record = match *record {
Some(ref rstate) => rstate.clone().with_value(ft_core::Steps {
date: self.date,
count: new_count,
}),
None => RecordState::New(ft_core::Steps {
date: self.date,
count: new_count,
}),
};
*record = Some(new_record);
}
pub fn save(&self) {
glib::spawn_future({
let s = self.clone();
async move {
if let Some(app) = s.app {
let weight_record = s.weight.read().unwrap().clone();
match weight_record {
Some(RecordState::New(weight)) => {
let _ = app.put_record(TraxRecord::Weight(weight)).await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(weight)) => {
let _ = app
.update_record(Record {
id: weight.id,
data: TraxRecord::Weight(weight.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let steps_record = s.steps.read().unwrap().clone();
match steps_record {
Some(RecordState::New(steps)) => {
let _ = app.put_record(TraxRecord::Steps(steps)).await;
}
Some(RecordState::Original(_)) => {}
Some(RecordState::Updated(steps)) => {
let _ = app
.update_record(Record {
id: steps.id,
data: TraxRecord::Steps(steps.data),
})
.await;
}
Some(RecordState::Deleted(_)) => {}
None => {}
}
let records = s
.records
.write()
.unwrap()
.drain()
.map(|(_, record)| record)
.collect::<Vec<RecordState<TraxRecord>>>();
for record in records {
match record {
RecordState::New(data) => {
let _ = app.put_record(data).await;
}
RecordState::Original(_) => {}
RecordState::Updated(r) => {
let _ = app.update_record(r.clone()).await;
}
RecordState::Deleted(_) => unimplemented!(),
}
}
}
}
});
}
pub fn revert(&self) {
unimplemented!();
}
}

View File

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

View File

@ -0,0 +1,76 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of FitnessTrax.
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{
components::{DayDetail, DayEdit, Singleton, SingletonImpl},
view_models::DayDetailViewModel,
};
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::cell::RefCell;
#[derive(Default)]
pub struct DayDetailViewPrivate {
container: Singleton,
view_model: RefCell<DayDetailViewModel>,
}
#[glib::object_subclass]
impl ObjectSubclass for DayDetailViewPrivate {
const NAME: &'static str = "DayDetailView";
type Type = DayDetailView;
type ParentType = gtk::Box;
}
impl ObjectImpl for DayDetailViewPrivate {}
impl WidgetImpl for DayDetailViewPrivate {}
impl BoxImpl for DayDetailViewPrivate {}
impl SingletonImpl for DayDetailViewPrivate {}
glib::wrapper! {
pub struct DayDetailView(ObjectSubclass<DayDetailViewPrivate>) @extends gtk::Box, gtk::Widget;
}
impl DayDetailView {
pub fn new(view_model: DayDetailViewModel) -> Self {
let s: Self = Object::builder().build();
*s.imp().view_model.borrow_mut() = view_model;
s.append(&s.imp().container);
s.view();
s
}
fn view(&self) {
self.imp()
.container
.swap(&DayDetail::new(self.imp().view_model.borrow().clone(), {
let s = self.clone();
move || s.edit()
}));
}
fn edit(&self) {
self.imp()
.container
.swap(&DayEdit::new(self.imp().view_model.borrow().clone(), {
let s = self.clone();
move || s.view()
}));
}
}

View File

@ -14,8 +14,10 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::DaySummary, types::DayInterval};
use chrono::{Duration, Local, NaiveDate};
use crate::{
app::App, components::DaySummary, types::DayInterval, view_models::DayDetailViewModel,
};
use chrono::NaiveDate;
use emseries::Record;
use ft_core::TraxRecord;
use glib::Object;
@ -26,7 +28,8 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc};
/// daily summaries, daily details, and will provide all functions the user may need for editing
/// records.
pub struct HistoricalViewPrivate {
time_window: RefCell<DayInterval>,
app: Rc<RefCell<Option<App>>>,
time_window: Rc<RefCell<DayInterval>>,
list_view: gtk::ListView,
}
@ -45,7 +48,17 @@ impl ObjectSubclass for HistoricalViewPrivate {
.set_child(Some(&DaySummary::new()));
});
factory.connect_bind(move |_, list_item| {
let s = Self {
app: Rc::new(RefCell::new(None)),
time_window: Rc::new(RefCell::new(DayInterval::default())),
list_view: gtk::ListView::builder()
.factory(&factory)
.single_click_activate(true)
.build(),
};
factory.connect_bind({
let app = s.app.clone();
move |_, list_item| {
let records = list_item
.downcast_ref::<gtk::ListItem>()
.expect("should be a ListItem")
@ -60,16 +73,17 @@ impl ObjectSubclass for HistoricalViewPrivate {
.and_downcast::<DaySummary>()
.expect("should be a DaySummary");
summary.set_data(records.date(), records.records());
if let Some(app) = app.borrow().clone() {
summary.set_data(DayDetailViewModel::new(
records.date(),
records.records(),
app.clone(),
));
}
}
});
Self {
time_window: RefCell::new(DayInterval::default()),
list_view: gtk::ListView::builder()
.factory(&factory)
.single_click_activate(true)
.build(),
}
s
}
}
@ -82,7 +96,11 @@ glib::wrapper! {
}
impl HistoricalView {
pub fn new<SelectFn>(records: Vec<Record<TraxRecord>>, on_select_day: Rc<SelectFn>) -> Self
pub fn new<SelectFn>(
app: App,
records: Vec<Record<TraxRecord>>,
on_select_day: Rc<SelectFn>,
) -> Self
where
SelectFn: Fn(chrono::NaiveDate, Vec<Record<TraxRecord>>) + 'static,
{
@ -90,6 +108,8 @@ impl HistoricalView {
s.set_orientation(gtk::Orientation::Vertical);
s.set_css_classes(&["historical"]);
*s.imp().app.borrow_mut() = Some(app);
let grouped_records =
GroupedRecords::new((*s.imp().time_window.borrow()).clone()).with_data(records);
@ -161,7 +181,7 @@ impl DayRecords {
}
pub fn date(&self) -> chrono::NaiveDate {
self.imp().date.borrow().clone()
*self.imp().date.borrow()
}
pub fn records(&self) -> Vec<Record<TraxRecord>> {
@ -204,11 +224,11 @@ impl GroupedRecords {
self
}
fn items<'a>(&'a self) -> impl Iterator<Item = DayRecords> + 'a {
fn items(&self) -> impl Iterator<Item = DayRecords> + '_ {
self.interval.days().map(|date| {
self.data
.get(&date)
.map(|rec| rec.clone())
.cloned()
.unwrap_or(DayRecords::new(date, vec![]))
})
}
@ -217,6 +237,7 @@ impl GroupedRecords {
#[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};
@ -272,7 +293,12 @@ mod test {
},
];
let groups = GroupedRecords::from(records).0;
let groups = GroupedRecords::new(DayInterval {
start: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
end: NaiveDate::from_ymd_opt(2023, 10, 14).unwrap(),
})
.with_data(records)
.data;
assert_eq!(groups.len(), 3);
}
}

View File

@ -16,6 +16,9 @@ You should have received a copy of the GNU General Public License along with Fit
use gtk::prelude::*;
mod day_detail_view;
pub use day_detail_view::DayDetailView;
mod historical_view;
pub use historical_view::HistoricalView;

View File

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

View File

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

View File

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

View File

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

View File

@ -65,14 +65,9 @@
gio-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
gdk-pixbuf-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
libadwaita-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
# fitnesstrax = attrs: {
# buildInputs = [
# pkgs.pkg-config
# pkgs.glib
# pkgs.gtk4
# pkgs.libadwaita
# ];
# };
dashboard = attrs: { nativeBuildInputs = gtkNativeInputs; };
fitnesstrax = attrs: { nativeBuildInputs = gtkNativeInputs; };
};
};
@ -80,11 +75,24 @@
nixpkgs = nixpkgs;
buildRustCrateForPkgs = cargoOverrides;
};
in {
in rec {
cyberpunk-splash = cargo_nix.workspaceMembers.cyberpunk-splash.build;
dashboard = cargo_nix.workspaceMembers.dashboard.build;
file-service = cargo_nix.workspaceMembers.file-service.build;
fitnesstrax = cargo_nix.workspaceMembers.fitnesstrax.build;
all = pkgs.symlinkJoin {
name = "all";
paths = [
cyberpunk-splash
dashboard
file-service
fitnesstrax
];
};
default = all;
};
};
}