Compare commits

...

8 Commits

Author SHA1 Message Date
Savanni D'Gerinel 56a8133dd5 A lot of work, possibly meaningless, to try to handle state within the main window
A note to self, when I return to this: the overlay modals don't make
sense in this context. The main window should have views, and switching
from one view to the next should involve just replacing the child.
Modals can be put off until later.
2023-12-18 17:49:25 -05:00
Savanni D'Gerinel 29dc81a991 Set up a database selector row that can dispatch operations when a database file gets selected 2023-12-18 17:17:39 -05:00
Savanni D'Gerinel 5668f1d7b2 Style up the modal and add the database file chooser widget 2023-12-18 12:44:12 -05:00
Savanni D'Gerinel f6bba16b26 Elaborate a little more on the welcome dialog 2023-12-18 12:09:54 -05:00
Savanni D'Gerinel 1140377aa5 Add a lot of commentary 2023-12-18 12:09:53 -05:00
Savanni D'Gerinel 87a07955a3 Start setting up an app modal 2023-12-18 12:08:14 -05:00
Savanni D'Gerinel 7ee3e1432e put in a placeholder for a historical view and the logic to choose it 2023-12-18 12:08:12 -05:00
Savanni D'Gerinel e008a97f83 Add a view which would be displayed when there is no database 2023-12-18 12:06:50 -05:00
7 changed files with 511 additions and 26 deletions

1
Cargo.lock generated
View File

@ -976,6 +976,7 @@ checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
name = "fitnesstrax"
version = "0.1.0"
dependencies = [
"emseries",
"ft-core",
"gio",
"glib",

View File

@ -7,10 +7,11 @@ edition = "2021"
[dependencies]
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
emseries = { path = "../../emseries" }
ft-core = { path = "../core" }
gio = { version = "0.18" }
glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_8" ] }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
tokio = { version = "1.34", features = [ "full" ] }
[build-dependencies]

View File

@ -0,0 +1,21 @@
.modal {
margin: 64px;
background-color: @view_bg_color;
border: 1px solid grey;
border-radius: 10px
}
.modal-title {
font-size: larger;
padding: 8px;
background-color: @headerbar_bg_color;
border-bottom: 1px solid @headerbar_border_color;
border-radius: 10px 10px 0px 0px;
}
.modal-content {
padding: 8px;
}
.modal-footer {
}

View File

@ -1,16 +1,270 @@
/*
Copyright 2023, 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/>.
*/
mod ui;
use adw::prelude::*;
use emseries::Series;
use ft_core::TraxRecord;
use gio::resources_lookup_data;
use gtk::{prelude::*, STYLE_PROVIDER_PRIORITY_USER};
use std::env;
use glib::Object;
use gtk::{subclass::prelude::*, STYLE_PROVIDER_PRIORITY_USER};
use std::{
cell::RefCell,
env,
sync::{Arc, RwLock},
};
use ui::{welcome_modal, Modal};
const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev";
const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax";
const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/";
struct AppState {}
/// The real, headless application. This is where all of the logic will reside.
#[derive(Clone)]
struct App {
database: Arc<RwLock<Option<Series<TraxRecord>>>>,
}
impl App {
pub fn new() -> Self {
Self {
database: Arc::new(RwLock::new(None)),
}
}
}
pub struct UnconfiguredViewPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for UnconfiguredViewPrivate {
const NAME: &'static str = "UnconfiguredView";
type Type = UnconfiguredView;
type ParentType = gtk::Box;
fn new() -> Self {
Self {}
}
}
impl ObjectImpl for UnconfiguredViewPrivate {}
impl WidgetImpl for UnconfiguredViewPrivate {}
impl BoxImpl for UnconfiguredViewPrivate {}
glib::wrapper! {
pub struct UnconfiguredView(ObjectSubclass<UnconfiguredViewPrivate>) @extends gtk::Box, gtk::Widget;
}
impl UnconfiguredView {
pub fn new() -> Self {
let s: Self = Object::builder().build();
let label = gtk::Label::builder()
.label("Database is not configured.")
.build();
s.append(&label);
s
}
}
pub struct HistoricalViewPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for HistoricalViewPrivate {
const NAME: &'static str = "HistoricalView";
type Type = HistoricalView;
type ParentType = gtk::Box;
fn new() -> Self {
Self {}
}
}
impl ObjectImpl for HistoricalViewPrivate {}
impl WidgetImpl for HistoricalViewPrivate {}
impl BoxImpl for HistoricalViewPrivate {}
glib::wrapper! {
pub struct HistoricalView(ObjectSubclass<HistoricalViewPrivate>) @extends gtk::Box, gtk::Widget;
}
impl HistoricalView {
pub fn new() -> Self {
let s: Self = Object::builder().build();
let label = gtk::Label::builder()
.label("Database has been configured and now it is time to show data")
.build();
s.append(&label);
s
}
}
// window setup...
// main window with all of its layout
// modals that overlay atop the main window and capture focus
// menus
// There is more than one view for the main window. There's the view for data entry, then another
// view for showing graphs. Then a third view, or maybe a modal, for editing a day.
//
// So, the ordinary data view is the history the metrics, and the calendar. Scrollable, and items
// within the scrolling area can be clicked upon in order to open the edit.
//
// I don't need to model the whole thing at once. The graphs will be some time out, and so I can
// model just the main view, which consists of metrics, the data, and the calendar. Day entries
// should be summaries of the day, expandable to the details.
//
// Then there is the view which notifies the user that the database has not been configured.
/// These are the possible states of the main application view.
enum MainView {
/// The application is not configured yet. This is a basic background widget to take up the
/// space when there is no data to be shown.
Unconfigured(UnconfiguredView),
/// The Historical view shows a history of records and whatnot.
Historical(HistoricalView),
}
/// The application window, or the main window, is the main user interface for the app.
struct AppWindow {
app: App,
window: adw::ApplicationWindow,
overlay: gtk::Overlay,
current_view: RefCell<MainView>,
// We have to keep around a reference to the modal so that we know what to remove from the
// overlay.
modal: RefCell<Option<gtk::Widget>>,
}
impl AppWindow {
/// Construct a new App Window.
///
/// adw_app is an Adwaita application. Application windows need to have access to this, but
/// otherwise we don't use this.
///
/// app is a core [App] object which encapsulates all of the basic logic.
fn new(adw_app: &adw::Application, app: App) -> AppWindow {
let window = adw::ApplicationWindow::builder()
.application(adw_app)
.width_request(800)
.height_request(600)
.build();
// GTK overlays aren't all that well documented. The Overlay object needs to be the
// content/child of the window. The main content should then be added to the overlay as
// `set_child`. The overlays/modals should be added as `add_overlay` and then removed with
// `remove_overlay`.
let overlay = gtk::Overlay::new();
window.set_content(Some(&overlay));
let current_view = if app.database.read().unwrap().is_none() {
let view = UnconfiguredView::new();
/*
overlay.set_child(Some(&view));
// I have to access the overlay directly here because I haven't fully constructed Self
// yet, and so I don't have access to `open_modal` yet.
*/
MainView::Unconfigured(view)
} else {
let view = HistoricalView::new();
/*
overlay.set_child(Some(&view));
*/
MainView::Historical(view)
};
let s = Self {
app,
window: window.clone(),
overlay,
current_view: RefCell::new(current_view),
modal: RefCell::new(None),
};
let stylesheet = String::from_utf8(
resources_lookup_data(
&format!("{}style.css", RESOURCE_BASE_PATH),
gio::ResourceLookupFlags::NONE,
)
.expect("stylesheet must be available in the resources")
.to_vec(),
)
.expect("to parse stylesheet");
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
let context = window.style_context();
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
window.present();
s.redraw();
s
/*
let current_view = if app.database.read().unwrap().is_none() {
let view = UnconfiguredView::new();
overlay.set_child(Some(&view));
// I have to access the overlay directly here because I haven't fully constructed Self
// yet, and so I don't have access to `open_modal` yet.
overlay.add_overlay(&welcome_modal(|path| {
// When a path gets selected, I want to set the path in the configuration system,
// and I want to open the database. After that, this window should be redrawn with
// its new state. So the view state also needs to change.
println!("path: {}", path.to_str().unwrap())
}));
MainView::Unconfigured(view)
} else {
let view = HistoricalView::new();
overlay.set_child(Some(&view));
MainView::HistoricalView(view)
};
*/
}
fn redraw(&self) {
match *self.current_view.borrow() {
MainView::Unconfigured(ref view) => {
self.overlay.set_child(Some(view));
let modal = welcome_modal(|path| {
// When a path gets selected, I want to set the path in the configuration system,
// and I want to open the database. After that, this window should be redrawn with
// its new state. So the view state also needs to change.
println!("path: {}", path.to_str().unwrap())
});
self.overlay.add_overlay(&modal);
*self.modal.borrow_mut() = Some(modal.upcast());
}
MainView::Historical(ref view) => self.overlay.set_child(Some(view)),
}
}
}
fn main() {
@ -31,11 +285,13 @@ fn main() {
println!("database path: {}", settings.string("series-path"));
let app = adw::Application::builder()
let adw_app = adw::Application::builder()
.application_id(app_id)
.resource_base_path(RESOURCE_BASE_PATH)
.build();
let app = App::new();
/*
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
@ -43,32 +299,15 @@ fn main() {
.unwrap();
*/
let app = adw::Application::builder()
let adw_app = adw::Application::builder()
.application_id(app_id)
.resource_base_path(RESOURCE_BASE_PATH)
.build();
app.connect_activate(move |app| {
let stylesheet = String::from_utf8(
resources_lookup_data(
&format!("{}style.css", RESOURCE_BASE_PATH),
gio::ResourceLookupFlags::NONE,
)
.expect("stylesheet must be available in the resources")
.to_vec(),
)
.expect("to parse stylesheet");
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
let window = adw::ApplicationWindow::new(app);
let context = window.style_context();
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
window.present();
adw_app.connect_activate(move |adw_app| {
AppWindow::new(adw_app, app.clone());
});
let args: Vec<String> = env::args().collect();
ApplicationExtManual::run_with_args(&app, &args);
ApplicationExtManual::run_with_args(&adw_app, &args);
}

View File

@ -0,0 +1,2 @@
mod modal;
pub use modal::{welcome_modal, Modal};

View File

@ -0,0 +1,220 @@
//! The Modal is a reusable component with a title, arbitrary content, and up to three action
//! buttons. It does not itself enforce being a modal, but is meant to become a child of an Overlay
//! component.
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{
cell::RefCell,
path::{Path, PathBuf},
rc::Rc,
};
pub struct ModalPrivate {
title: gtk::Label,
content: RefCell<gtk::Widget>,
primary_action: RefCell<gtk::Button>,
secondary_action: RefCell<Option<gtk::Button>>,
tertiary_action: RefCell<Option<gtk::Button>>,
footer: gtk::Box,
}
#[glib::object_subclass]
impl ObjectSubclass for ModalPrivate {
const NAME: &'static str = "Modal";
type Type = Modal;
type ParentType = gtk::Box;
fn new() -> Self {
let title = gtk::Label::builder()
.label("Modal")
.css_classes(["modal-title"])
.build();
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
let primary_action = gtk::Button::builder().label("Primary").build();
let footer = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.hexpand(true)
.css_classes(["modal-footer"])
.build();
footer.append(&primary_action);
Self {
title,
content: RefCell::new(content.upcast()),
primary_action: RefCell::new(primary_action),
secondary_action: RefCell::new(None),
tertiary_action: RefCell::new(None),
footer,
}
}
}
impl ObjectImpl for ModalPrivate {}
impl WidgetImpl for ModalPrivate {}
impl BoxImpl for ModalPrivate {}
glib::wrapper! {
pub struct Modal(ObjectSubclass<ModalPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Modal {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_css_classes(&["modal"]);
s.set_orientation(gtk::Orientation::Vertical);
s.append(&s.imp().title);
s.append(&*s.imp().content.borrow());
s.append(&s.imp().footer);
s
}
pub fn set_title(&self, text: &str) {
self.imp().title.set_text(text);
}
pub fn set_content(&self, content: gtk::Widget) {
self.remove(&*self.imp().content.borrow());
content.add_css_class("modal-content");
content.set_hexpand(true);
content.set_vexpand(true);
self.insert_child_after(&content, Some(&self.imp().title));
*self.imp().content.borrow_mut() = content;
}
pub fn set_primary_action(&self, action: gtk::Button) {
self.imp()
.footer
.remove(&*self.imp().primary_action.borrow());
*self.imp().primary_action.borrow_mut() = action;
self.imp()
.footer
.append(&*self.imp().primary_action.borrow());
}
}
/// The welcome modal is the first thing the user will see when FitnessTrax starts up if the
/// database has not been configured yet.
///
/// This is a [Modal] component with all of the welcome content.
pub fn welcome_modal<F>(database_selected: F) -> Modal
where
F: Fn(&Path) + 'static,
{
let modal = Modal::new();
modal.set_title("Welcome to FitnessTrax");
// The content should be a friendly dialog that explains to the user that they're going to set
// up the database.
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
let instructions = gtk::Label::builder()
.wrap(true)
.label("The application has not yet been configured, so I will walk you through that. Fortunately, it is very easy, and we only need to select the location for your database.").build();
content.append(&instructions);
let db_row = DatabaseFileChooserRow::new(database_selected);
content.append(&db_row);
modal.set_content(content.upcast());
modal.set_primary_action(gtk::Button::builder().label("Save Settings").build());
modal
}
pub struct DatabaseFileChooserRowPrivate {
path: RefCell<Option<PathBuf>>,
label: gtk::Label,
on_selected_: RefCell<Box<dyn Fn(&Path)>>,
}
#[glib::object_subclass]
impl ObjectSubclass for DatabaseFileChooserRowPrivate {
const NAME: &'static str = "DatabaseFileChooser";
type Type = DatabaseFileChooserRow;
type ParentType = gtk::Box;
fn new() -> Self {
Self {
path: RefCell::new(None),
label: gtk::Label::builder().hexpand(true).build(),
on_selected_: RefCell::new(Box::new(|_| {})),
}
}
}
impl ObjectImpl for DatabaseFileChooserRowPrivate {}
impl WidgetImpl for DatabaseFileChooserRowPrivate {}
impl BoxImpl for DatabaseFileChooserRowPrivate {}
glib::wrapper! {
pub struct DatabaseFileChooserRow(ObjectSubclass<DatabaseFileChooserRowPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl DatabaseFileChooserRow {
pub fn new<F>(database_selected: F) -> Self
where
F: Fn(&Path) + 'static,
{
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Horizontal);
// The database selection row should be a box that shows a default database path, along with a
// button that triggers a file chooser dialog. Once the dialog returns, the box should be
// updated to reflect the chosen path.
s.imp().label.set_text("No database selected");
*s.imp().on_selected_.borrow_mut() = Box::new(database_selected);
let db_file_chooser_button = gtk::Button::builder().label("Select Database").build();
db_file_chooser_button.connect_clicked({
let s = s.clone();
move |_| {
let no_window: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let s = s.clone();
gtk::FileDialog::builder().build().open(
no_window,
not_cancellable,
move |file_id| s.on_selected(file_id),
);
}
});
s.append(&s.imp().label);
s.append(&db_file_chooser_button);
s
}
fn on_selected(&self, m_file_id: Result<gio::File, glib::Error>) {
match m_file_id {
Ok(file_id) => {
println!("The user selected {:?}", file_id.path());
*self.imp().path.borrow_mut() = file_id.path();
match *self.imp().path.borrow() {
Some(ref path) => {
(*self.imp().on_selected_.borrow())(path);
self.redraw();
}
None => {}
}
}
Err(err) => println!("file opening failed: {}", err),
}
}
fn redraw(&self) {
match *self.imp().path.borrow() {
Some(ref path) => self.imp().label.set_text(path.to_str().unwrap()),
None => self.imp().label.set_text("No database selected"),
}
}
}

View File

@ -4,3 +4,4 @@ use emseries::DateTimeTz;
mod legacy;
mod types;
pub use types::TraxRecord;