diff --git a/fitnesstrax/app/Cargo.toml b/fitnesstrax/app/Cargo.toml index 470ecea..66f08ef 100644 --- a/fitnesstrax/app/Cargo.toml +++ b/fitnesstrax/app/Cargo.toml @@ -11,7 +11,7 @@ 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] diff --git a/fitnesstrax/app/resources/style.css b/fitnesstrax/app/resources/style.css index e69de29..314fd86 100644 --- a/fitnesstrax/app/resources/style.css +++ b/fitnesstrax/app/resources/style.css @@ -0,0 +1,21 @@ +.welcome { + margin: 64px; +} + +.welcome-title { + font-size: larger; + padding: 8px; +} + +.welcome-content { + padding: 8px; +} + +.welcome-footer { +} + +.dialog-row { + margin: 8px 0px 8px 0px; + padding: 8px; +} + diff --git a/fitnesstrax/app/src/main.rs b/fitnesstrax/app/src/main.rs index dcb3748..1eb412e 100644 --- a/fitnesstrax/app/src/main.rs +++ b/fitnesstrax/app/src/main.rs @@ -13,8 +13,11 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ + +mod ui; + use adw::prelude::*; -use emseries::Series; +use emseries::{EmseriesReadError, Series}; use ft_core::TraxRecord; use gio::resources_lookup_data; use glib::Object; @@ -22,36 +25,63 @@ use gtk::{subclass::prelude::*, STYLE_PROVIDER_PRIORITY_USER}; use std::{ cell::RefCell, env, + path::{Path, PathBuf}, + rc::Rc, sync::{Arc, RwLock}, }; +use ui::FileChooserRow; 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/"; +/// A set of events that can occur at the global application level. These events should represent +/// significant state changes that should go through a central dispatcher. +enum Events { + DatabaseChanged(Series), +} +// Note that I have not yet figured out the communication channel or how the central dispatcher +// should work. There's a dance between the App and the AppWindow that I haven't figured out yet. + /// The real, headless application. This is where all of the logic will reside. #[derive(Clone)] struct App { + settings: gio::Settings, database: Arc>>>, } impl App { - pub fn new() -> Self { - Self { + pub fn new(settings: gio::Settings) -> Self { + let s = Self { + settings, database: Arc::new(RwLock::new(None)), + }; + + if !s.settings.string("series-path").is_empty() { + let path = PathBuf::from(s.settings.string("series-path")); + let db = Series::open(path).unwrap(); + *s.database.write().unwrap() = Some(db); } + + s + } + + pub fn open_db(&self, path: &Path) { + let db = Series::open(path).unwrap(); + *self.database.write().unwrap() = Some(db); + self.settings + .set_string("series-path", path.to_str().unwrap()) + .unwrap(); } } -/// This is the view to show if the application has not yet been configured. It will walk the user -/// through the most critical setup steps so that we can move on to the other views in the app. -pub struct UnconfiguredViewPrivate {} +pub struct PlaceholderViewPrivate {} #[glib::object_subclass] -impl ObjectSubclass for UnconfiguredViewPrivate { - const NAME: &'static str = "UnconfiguredView"; - type Type = UnconfiguredView; +impl ObjectSubclass for PlaceholderViewPrivate { + const NAME: &'static str = "PlaceholderView"; + type Type = PlaceholderView; type ParentType = gtk::Box; fn new() -> Self { @@ -59,24 +89,95 @@ impl ObjectSubclass for UnconfiguredViewPrivate { } } -impl ObjectImpl for UnconfiguredViewPrivate {} -impl WidgetImpl for UnconfiguredViewPrivate {} -impl BoxImpl for UnconfiguredViewPrivate {} +impl ObjectImpl for PlaceholderViewPrivate {} +impl WidgetImpl for PlaceholderViewPrivate {} +impl BoxImpl for PlaceholderViewPrivate {} glib::wrapper! { - pub struct UnconfiguredView(ObjectSubclass) @extends gtk::Box, gtk::Widget; + pub struct PlaceholderView(ObjectSubclass) @extends gtk::Box, gtk::Widget; } -impl UnconfiguredView { +impl PlaceholderView { pub fn new() -> Self { let s: Self = Object::builder().build(); + s + } +} + +/// This is the view to show if the application has not yet been configured. It will walk the user +/// through the most critical setup steps so that we can move on to the other views in the app. +pub struct WelcomeViewPrivate {} + +#[glib::object_subclass] +impl ObjectSubclass for WelcomeViewPrivate { + const NAME: &'static str = "WelcomeView"; + type Type = WelcomeView; + type ParentType = gtk::Box; + + fn new() -> Self { + Self {} + } +} + +impl ObjectImpl for WelcomeViewPrivate {} +impl WidgetImpl for WelcomeViewPrivate {} +impl BoxImpl for WelcomeViewPrivate {} + +glib::wrapper! { + pub struct WelcomeView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + +impl WelcomeView { + pub fn new(on_save: Box) -> Self + where + F: Fn(PathBuf) + 'static, + { + let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); + s.set_css_classes(&["welcome"]); // Replace this with the welcome screen that we set up in the fitnesstrax/unconfigured-page // branch. - let label = gtk::Label::builder() - .label("Database is not configured.") + let title = gtk::Label::builder() + .label("Welcome to FitnessTrax") + .css_classes(["welcome-title"]) .build(); - s.append(&label); + + let content = gtk::Box::builder() + .css_classes(["model-content"]) + .orientation(gtk::Orientation::Vertical) + .vexpand(true) + .build(); + + let save_button = gtk::Button::builder() + .label("Save Settings") + .sensitive(false) + .build(); + + // 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. + let db_row = FileChooserRow::new({ + let save_button = save_button.clone(); + move |_| save_button.set_sensitive(true) + }); + + content.append(>k::Label::new(Some("Welcome to FitnessTrax. The application has not yet been configured, so I will walk you through that. Let's start out by selecting your database."))); + content.append(&db_row); + + let on_save = on_save; + save_button.connect_clicked({ + move |_| { + if let Some(path) = db_row.path() { + on_save(path) + } + } + }); + + s.append(&title); + s.append(&content); + s.append(&save_button); + s } } @@ -119,11 +220,12 @@ impl HistoricalView { /// The application window, or the main window, is the main user interface for the app. Almost /// everything occurs here. +#[derive(Clone)] struct AppWindow { app: App, window: adw::ApplicationWindow, layout: gtk::Box, - current_view: RefCell, + current_view: Rc>, } impl AppWindow { @@ -140,12 +242,6 @@ impl AppWindow { .height_request(600) .build(); - let current_view = if app.database.read().unwrap().is_none() { - UnconfiguredView::new().upcast() - } else { - HistoricalView::new().upcast() - }; - let stylesheet = String::from_utf8( resources_lookup_data( &format!("{}style.css", RESOURCE_BASE_PATH), @@ -169,18 +265,47 @@ impl AppWindow { let layout = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); + + let initial_view = PlaceholderView::new(); + layout.append(&header); - layout.append(¤t_view); + layout.append(&initial_view); window.set_content(Some(&layout)); window.present(); let s = Self { - app, + app: app.clone(), window, layout, - current_view: RefCell::new(current_view), + current_view: Rc::new(RefCell::new(initial_view.upcast())), }; + + let initial_view = if app.database.read().unwrap().is_none() { + WelcomeView::new({ + let app = app.clone(); + let s = s.clone(); + Box::new(move |path: PathBuf| { + // The user has selected a path. Perhaps the path is new, perhaps it already + // exists. + // + // If the file exists already, attempt to read it. Fail if that doesn't work. + // A should show to the user something that indicates that the file exists but is + // not already a database. + // + // If the file does not exist, create a new one. Again, show the user an error if + // some kind of error occurs. + app.open_db(&path); + s.change_view(HistoricalView::new().upcast()); + }) + }) + .upcast() + } else { + HistoricalView::new().upcast() + }; + + s.change_view(initial_view); + s } @@ -215,7 +340,7 @@ fn main() { println!("database path: {}", settings.string("series-path")); - let app = App::new(); + let app = App::new(settings); /* let runtime = tokio::runtime::Builder::new_multi_thread() diff --git a/fitnesstrax/app/src/ui/mod.rs b/fitnesstrax/app/src/ui/mod.rs new file mode 100644 index 0000000..14f1243 --- /dev/null +++ b/fitnesstrax/app/src/ui/mod.rs @@ -0,0 +1,131 @@ +/* +Copyright 2023, Savanni D'Gerinel + +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 . +*/ + +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + rc::Rc, +}; + +pub struct FileChooserRowPrivate { + path: RefCell>, + label: gtk::Label, +} + +#[glib::object_subclass] +impl ObjectSubclass for FileChooserRowPrivate { + const NAME: &'static str = "FileChooser"; + type Type = FileChooserRow; + type ParentType = gtk::Box; + + fn new() -> Self { + Self { + path: RefCell::new(None), + label: gtk::Label::builder().hexpand(true).build(), + } + } +} + +impl ObjectImpl for FileChooserRowPrivate {} +impl WidgetImpl for FileChooserRowPrivate {} +impl BoxImpl for FileChooserRowPrivate {} + +glib::wrapper! { + pub struct FileChooserRow(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + +impl FileChooserRow { + pub fn new(on_selected: F) -> Self + where + F: Fn(PathBuf) + 'static, + { + let s: Self = Object::builder().build(); + + s.set_css_classes(&["dialog-row", "card"]); + s.set_orientation(gtk::Orientation::Horizontal); + s.set_spacing(8); + + // 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"); + + let on_selected = Rc::new(Box::new(on_selected)); + + let import_button = gtk::Button::builder().label("Import a Database").build(); + + let handle_file_selection = Rc::new(Box::new({ + let s = s.clone(); + let on_selected = on_selected.clone(); + move |file_id: Result| match file_id { + Ok(file_id) => match file_id.path() { + Some(path) => { + s.imp().label.set_text(path.to_str().unwrap()); + on_selected(path.clone()); + *s.imp().path.borrow_mut() = Some(path); + } + None => { + *s.imp().path.borrow_mut() = None; + s.imp().label.set_text("No database selected"); + } + }, + Err(err) => println!("file opening failed: {}", err), + } + })); + + import_button.connect_clicked({ + let handle_file_selection = handle_file_selection.clone(); + move |_| { + let no_window: Option<>k::Window> = None; + let not_cancellable: Option<&gio::Cancellable> = None; + let handle_file_selection = handle_file_selection.clone(); + gtk::FileDialog::builder().build().open( + no_window, + not_cancellable, + move |file_id| handle_file_selection(file_id), + ); + } + }); + + let new_button = gtk::Button::builder().label("Create Database").build(); + new_button.connect_clicked({ + let handle_file_selection = handle_file_selection.clone(); + move |_| { + let no_window: Option<>k::Window> = None; + let not_cancellable: Option<&gio::Cancellable> = None; + let handle_file_selection = handle_file_selection.clone(); + gtk::FileDialog::builder().build().save( + no_window, + not_cancellable, + move |file_id| handle_file_selection(file_id), + ); + } + }); + + s.imp().label.set_halign(gtk::Align::Start); + s.append(&s.imp().label); + s.append(&import_button); + s.append(&new_button); + + s + } + + pub fn path(&self) -> Option { + self.imp().path.borrow().clone() + } +}