2023-12-18 23:36:22 +00:00
/*
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/>.
* /
2023-12-19 02:14:08 +00:00
mod ui ;
2023-12-18 23:30:41 +00:00
use adw ::prelude ::* ;
2023-12-19 23:02:35 +00:00
use async_channel ::{ Receiver , Sender } ;
2023-12-19 15:46:53 +00:00
use emseries ::{ EmseriesReadError , Series } ;
2023-12-18 23:30:41 +00:00
use ft_core ::TraxRecord ;
2023-12-18 16:59:56 +00:00
use gio ::resources_lookup_data ;
2023-12-18 23:30:41 +00:00
use glib ::Object ;
2023-12-19 00:08:32 +00:00
use gtk ::{ subclass ::prelude ::* , STYLE_PROVIDER_PRIORITY_USER } ;
2023-12-18 23:30:41 +00:00
use std ::{
cell ::RefCell ,
env ,
2023-12-19 15:59:33 +00:00
path ::{ Path , PathBuf } ,
2023-12-19 05:31:36 +00:00
rc ::Rc ,
2023-12-18 23:30:41 +00:00
sync ::{ Arc , RwLock } ,
} ;
2023-12-19 02:14:08 +00:00
use ui ::FileChooserRow ;
2023-11-20 03:47:36 +00:00
2023-12-07 14:45:56 +00:00
const APP_ID_DEV : & str = " com.luminescent-dreams.fitnesstrax.dev " ;
2023-12-07 14:56:10 +00:00
const APP_ID_PROD : & str = " com.luminescent-dreams.fitnesstrax " ;
2023-12-07 14:45:56 +00:00
2023-12-18 16:59:56 +00:00
const RESOURCE_BASE_PATH : & str = " /com/luminescent-dreams/fitnesstrax/ " ;
2023-12-07 14:45:56 +00:00
2023-12-19 23:02:35 +00:00
#[ derive(Debug) ]
enum AppInvocation {
OpenDatabase ( PathBuf ) ,
2023-12-22 19:08:16 +00:00
RequestRecords ,
2023-12-19 15:46:53 +00:00
}
2023-12-19 23:02:35 +00:00
#[ derive(Debug) ]
enum AppResponse {
2023-12-22 19:08:16 +00:00
NoDatabase ,
Records ,
DatabaseChanged ( PathBuf ) ,
2023-12-19 23:02:35 +00:00
}
2023-12-19 15:46:53 +00:00
// 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.
2023-12-18 23:30:41 +00:00
/// 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 {
2023-12-22 19:08:16 +00:00
pub fn new ( db_path : Option < PathBuf > ) -> Self {
let database = db_path . map ( | path | Series ::open ( path ) . unwrap ( ) ) ;
2023-12-19 15:59:33 +00:00
let s = Self {
2023-12-22 19:08:16 +00:00
database : Arc ::new ( RwLock ::new ( database ) ) ,
2023-12-19 15:59:33 +00:00
} ;
s
}
2023-12-22 19:08:16 +00:00
pub async fn process_invocation ( & self , invocation : AppInvocation ) -> AppResponse {
match invocation {
AppInvocation ::OpenDatabase ( db_path ) = > {
self . open_db ( & db_path ) ;
AppResponse ::DatabaseChanged ( db_path )
}
AppInvocation ::RequestRecords = > {
if self . database . read ( ) . unwrap ( ) . is_none ( ) {
AppResponse ::NoDatabase
} else {
AppResponse ::Records
}
}
}
}
2023-12-19 15:59:33 +00:00
pub fn open_db ( & self , path : & Path ) {
let db = Series ::open ( path ) . unwrap ( ) ;
* self . database . write ( ) . unwrap ( ) = Some ( db ) ;
2023-12-18 23:30:41 +00:00
}
}
2023-12-19 15:46:53 +00:00
pub struct PlaceholderViewPrivate { }
#[ glib::object_subclass ]
impl ObjectSubclass for PlaceholderViewPrivate {
const NAME : & 'static str = " PlaceholderView " ;
type Type = PlaceholderView ;
type ParentType = gtk ::Box ;
fn new ( ) -> Self {
Self { }
}
}
impl ObjectImpl for PlaceholderViewPrivate { }
impl WidgetImpl for PlaceholderViewPrivate { }
impl BoxImpl for PlaceholderViewPrivate { }
glib ::wrapper! {
pub struct PlaceholderView ( ObjectSubclass < PlaceholderViewPrivate > ) @ extends gtk ::Box , gtk ::Widget ;
}
impl PlaceholderView {
pub fn new ( ) -> Self {
let s : Self = Object ::builder ( ) . build ( ) ;
s
}
}
2023-12-18 23:36:22 +00:00
/// 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.
2023-12-19 01:04:55 +00:00
pub struct WelcomeViewPrivate { }
2023-12-18 23:30:41 +00:00
#[ glib::object_subclass ]
2023-12-19 01:04:55 +00:00
impl ObjectSubclass for WelcomeViewPrivate {
const NAME : & 'static str = " WelcomeView " ;
type Type = WelcomeView ;
2023-12-18 23:30:41 +00:00
type ParentType = gtk ::Box ;
fn new ( ) -> Self {
Self { }
}
}
2023-12-19 01:04:55 +00:00
impl ObjectImpl for WelcomeViewPrivate { }
impl WidgetImpl for WelcomeViewPrivate { }
impl BoxImpl for WelcomeViewPrivate { }
2023-12-18 23:30:41 +00:00
glib ::wrapper! {
2023-12-19 01:04:55 +00:00
pub struct WelcomeView ( ObjectSubclass < WelcomeViewPrivate > ) @ extends gtk ::Box , gtk ::Widget , @ implements gtk ::Orientable ;
2023-12-18 23:30:41 +00:00
}
2023-12-19 01:04:55 +00:00
impl WelcomeView {
2023-12-19 15:46:53 +00:00
pub fn new < F > ( on_save : Box < F > ) -> Self
2023-12-19 05:31:36 +00:00
where
F : Fn ( PathBuf ) + 'static ,
{
2023-12-18 23:30:41 +00:00
let s : Self = Object ::builder ( ) . build ( ) ;
2023-12-19 01:04:55 +00:00
s . set_orientation ( gtk ::Orientation ::Vertical ) ;
2023-12-19 15:10:02 +00:00
s . set_css_classes ( & [ " welcome " ] ) ;
2023-12-18 23:30:41 +00:00
2023-12-18 23:36:22 +00:00
// Replace this with the welcome screen that we set up in the fitnesstrax/unconfigured-page
// branch.
2023-12-19 02:14:08 +00:00
let title = gtk ::Label ::builder ( )
2023-12-19 01:04:55 +00:00
. label ( " Welcome to FitnessTrax " )
2023-12-19 15:10:02 +00:00
. css_classes ( [ " welcome-title " ] )
2023-12-18 23:30:41 +00:00
. build ( ) ;
2023-12-19 01:04:55 +00:00
2023-12-19 02:14:08 +00:00
let content = gtk ::Box ::builder ( )
. css_classes ( [ " model-content " ] )
. orientation ( gtk ::Orientation ::Vertical )
. vexpand ( true )
. build ( ) ;
2023-12-19 01:04:55 +00:00
2023-12-19 05:31:36 +00:00
let save_button = gtk ::Button ::builder ( )
. label ( " Save Settings " )
. sensitive ( false )
. build ( ) ;
2023-12-19 01:04:55 +00:00
// 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.
2023-12-19 05:31:36 +00:00
let db_row = FileChooserRow ::new ( {
let save_button = save_button . clone ( ) ;
move | _ | save_button . set_sensitive ( true )
} ) ;
2023-12-19 02:14:08 +00:00
2023-12-19 05:31:36 +00:00
content . append ( & gtk ::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. " ) ) ) ;
2023-12-19 02:14:08 +00:00
content . append ( & db_row ) ;
2023-12-19 15:46:53 +00:00
let on_save = on_save ;
2023-12-19 05:31:36 +00:00
save_button . connect_clicked ( {
move | _ | {
if let Some ( path ) = db_row . path ( ) {
on_save ( path )
}
}
} ) ;
s . append ( & title ) ;
2023-12-19 02:14:08 +00:00
s . append ( & content ) ;
2023-12-19 05:31:36 +00:00
s . append ( & save_button ) ;
2023-12-19 01:04:55 +00:00
2023-12-18 23:30:41 +00:00
s
}
}
2023-12-18 23:36:22 +00:00
/// 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
/// records.
2023-12-18 23:30:41 +00:00
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 { }
}
}
2023-11-20 03:47:36 +00:00
2023-12-18 23:30:41 +00:00
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
}
}
2023-12-22 19:08:16 +00:00
#[ derive(Clone, Debug, PartialEq) ]
enum ViewName {
Placeholder ,
Welcome ,
Historical ,
}
enum View {
Placeholder ( gtk ::Widget ) ,
Welcome ( gtk ::Widget ) ,
Historical ( gtk ::Widget ) ,
}
impl View {
fn widget < ' a > ( & ' a self ) -> & ' a gtk ::Widget {
match self {
View ::Placeholder ( widget ) = > widget ,
View ::Welcome ( widget ) = > widget ,
View ::Historical ( widget ) = > widget ,
}
}
}
2023-12-18 23:30:41 +00:00
/// The application window, or the main window, is the main user interface for the app. Almost
/// everything occurs here.
2023-12-19 15:46:53 +00:00
#[ derive(Clone) ]
2023-11-20 03:47:36 +00:00
struct AppWindow {
2023-12-19 23:02:35 +00:00
app_tx : Sender < AppInvocation > ,
2023-11-20 03:47:36 +00:00
window : adw ::ApplicationWindow ,
2023-12-19 00:08:32 +00:00
layout : gtk ::Box ,
2023-12-22 19:08:16 +00:00
current_view : Rc < RefCell < View > > ,
settings : gio ::Settings ,
2023-12-18 23:30:41 +00:00
}
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.
2023-12-22 19:08:16 +00:00
fn new ( app_id : & str , adw_app : & adw ::Application , app_tx : Sender < AppInvocation > ) -> AppWindow {
2023-12-18 23:30:41 +00:00
let window = adw ::ApplicationWindow ::builder ( )
. application ( adw_app )
. width_request ( 800 )
. height_request ( 600 )
. build ( ) ;
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 ) ;
2023-12-19 00:08:32 +00:00
let header = adw ::HeaderBar ::builder ( )
. title_widget ( & gtk ::Label ::new ( Some ( " FitnessTrax " ) ) )
. build ( ) ;
let layout = gtk ::Box ::builder ( )
. orientation ( gtk ::Orientation ::Vertical )
. build ( ) ;
2023-12-19 15:46:53 +00:00
2023-12-22 19:08:16 +00:00
let initial_view = View ::Placeholder ( PlaceholderView ::new ( ) . upcast ( ) ) ;
2023-12-19 15:46:53 +00:00
2023-12-19 00:08:32 +00:00
layout . append ( & header ) ;
2023-12-22 19:08:16 +00:00
layout . append ( initial_view . widget ( ) ) ;
2023-12-19 00:08:32 +00:00
window . set_content ( Some ( & layout ) ) ;
2023-12-18 23:30:41 +00:00
window . present ( ) ;
let s = Self {
2023-12-19 23:02:35 +00:00
app_tx ,
2023-12-18 23:30:41 +00:00
window ,
2023-12-19 00:08:32 +00:00
layout ,
2023-12-22 19:08:16 +00:00
current_view : Rc ::new ( RefCell ::new ( initial_view ) ) ,
settings : gio ::Settings ::new ( app_id ) ,
2023-12-19 15:46:53 +00:00
} ;
2023-12-22 19:08:16 +00:00
s
}
2023-12-19 15:46:53 +00:00
2023-12-22 19:08:16 +00:00
pub fn change_view ( & self , view : ViewName ) {
self . swap_main ( self . construct_view ( view ) ) ;
}
2023-12-19 15:46:53 +00:00
2023-12-22 19:08:16 +00:00
pub fn process_response ( & self , response : AppResponse ) {
match response {
AppResponse ::DatabaseChanged ( db_path ) = > {
self . settings
. set_string ( " series-path " , db_path . to_str ( ) . unwrap ( ) )
. unwrap ( ) ;
self . change_view ( ViewName ::Historical ) ;
}
AppResponse ::NoDatabase = > {
self . change_view ( ViewName ::Welcome ) ;
}
AppResponse ::Records = > {
self . change_view ( ViewName ::Historical ) ;
}
}
2023-12-18 23:30:41 +00:00
}
2023-12-18 23:36:22 +00:00
// Switch views.
//
// This function only replaces the old view with the one which matches the current view state.
2023-12-19 00:08:32 +00:00
// It is responsible for ensuring that the new view goes into the layout in the correct
// position.
2023-12-22 19:08:16 +00:00
fn swap_main ( & self , view : View ) {
let mut current_widget = self . current_view . borrow_mut ( ) ;
self . layout . remove ( & * current_widget . widget ( ) ) ;
* current_widget = view ;
self . layout . append ( & * current_widget . widget ( ) ) ;
}
fn construct_view ( & self , view : ViewName ) -> View {
match view {
ViewName ::Placeholder = > View ::Placeholder ( PlaceholderView ::new ( ) . upcast ( ) ) ,
ViewName ::Welcome = > View ::Welcome (
WelcomeView ::new ( {
let s = self . clone ( ) ;
Box ::new ( move | path : PathBuf | {
s . app_tx
. send_blocking ( AppInvocation ::OpenDatabase ( path ) )
. unwrap ( ) ;
} )
} )
. upcast ( ) ,
) ,
ViewName ::Historical = > View ::Historical ( HistoricalView ::new ( ) . upcast ( ) ) ,
}
2023-12-18 23:30:41 +00:00
}
2023-11-20 03:47:36 +00:00
}
2023-11-13 13:52:10 +00:00
fn main ( ) {
2023-12-18 16:59:56 +00:00
// I still don't fully understand gio resources. resources_register_include! is convenient
// because I don't have to deal with filesystem locations at runtime. However, I think other
// GTK applications do that rather than compiling the resources directly into the app. So, I'm
// unclear as to how I want to handle this.
gio ::resources_register_include! ( " com.luminescent-dreams.fitnesstrax.gresource " )
. expect ( " to register resources " ) ;
let app_id = if std ::env ::var_os ( " ENV " ) = = Some ( " dev " . into ( ) ) {
APP_ID_DEV
2023-12-07 14:56:10 +00:00
} else {
2023-12-18 16:59:56 +00:00
APP_ID_PROD
2023-12-07 14:56:10 +00:00
} ;
2023-12-07 14:45:56 +00:00
let settings = gio ::Settings ::new ( app_id ) ;
2023-12-22 19:08:16 +00:00
let app = App ::new ( {
let path = settings . string ( " series-path " ) ;
if path . is_empty ( ) {
None
} else {
Some ( PathBuf ::from ( path ) )
}
} ) ;
2023-11-20 03:47:36 +00:00
2023-12-18 23:30:41 +00:00
let adw_app = adw ::Application ::builder ( )
2023-12-18 16:59:56 +00:00
. application_id ( app_id )
. resource_base_path ( RESOURCE_BASE_PATH )
2023-11-20 03:47:36 +00:00
. build ( ) ;
2023-12-19 23:02:35 +00:00
let runtime = tokio ::runtime ::Builder ::new_multi_thread ( )
. enable_all ( )
. build ( )
. unwrap ( ) ;
2023-12-18 23:30:41 +00:00
adw_app . connect_activate ( move | adw_app | {
2023-12-19 23:02:35 +00:00
let ( gtk_tx , gtk_rx ) = async_channel ::unbounded ::< AppResponse > ( ) ;
let ( app_tx , app_rx ) = async_channel ::unbounded ::< AppInvocation > ( ) ;
2023-12-22 19:08:16 +00:00
let window = AppWindow ::new ( app_id , adw_app , app_tx . clone ( ) ) ;
2023-12-19 23:02:35 +00:00
glib ::spawn_future_local ( async move {
2023-12-22 19:08:16 +00:00
// The app requests data to start with. This kicks everything off. The response from
// the app will cause the window to be updated shortly.
let _ = app_tx . send ( AppInvocation ::RequestRecords ) . await ;
2023-12-19 23:02:35 +00:00
while let Ok ( response ) = gtk_rx . recv ( ) . await {
println! ( " response received: {:?} " , response ) ;
2023-12-22 19:08:16 +00:00
window . process_response ( response ) ;
2023-12-19 23:02:35 +00:00
}
} ) ;
2023-12-22 19:08:16 +00:00
runtime . spawn ( {
let app = app . clone ( ) ;
async move {
while let Ok ( invocation ) = app_rx . recv ( ) . await {
let response = app . process_invocation ( invocation ) . await ;
let _ = gtk_tx . send ( response ) . await ;
}
2023-12-19 23:02:35 +00:00
}
} ) ;
2023-11-20 03:47:36 +00:00
} ) ;
let args : Vec < String > = env ::args ( ) . collect ( ) ;
2023-12-18 23:30:41 +00:00
ApplicationExtManual ::run_with_args ( & adw_app , & args ) ;
2023-11-13 13:52:10 +00:00
}